mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # packages/client/runtime/src/client/sessions/fold-adapter.ts
This commit is contained in:
@@ -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
|
||||
2026-07-23-trajectory-step-cell.md: 414c3aac856fb5e60f0e4cf42f8e7b410cdf3413
|
||||
2026-07-23-trajectory-step-cell.zh.md: aa76b422f165ebf6918b3781fdfe38797a34ba51
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Trajectory step cell and turn list chrome
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-23-trajectory-step-cell.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The trajectory tab needs a reusable step row and turn-list chrome that can show expanded assistant blocks, own-duration times, Message token columns, and in-flight work. Without folding session event times into conversation nodes and expanding blocks into cells, the UI cannot match the product chrome.
|
||||
|
||||
## Decision
|
||||
|
||||
[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) owns the presentational trajectory list chrome:
|
||||
|
||||
- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection.
|
||||
- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8.
|
||||
- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only (including the empty fallback when there is no text block), and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). `user/message` has no wire turn, so each User row is enclosed in the next assistant/steering turn, else the in-flight `partial` turn, else `lastAssistantTurn + 1` (or `1`). Context nodes emit no cell but still advance the Message duration cursor.
|
||||
|
||||
[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time (including skipped context); Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep a Think cell for reasoning blocks.** Rejected: a single `assistant/message.time` cannot yield Think own-duration without chunk-level clocks; omit the row rather than show `—`.
|
||||
|
||||
**Keep separate Call and Result rows.** Rejected: Result had no own duration to show; one Tool row carries the call→result interval.
|
||||
|
||||
**Cumulative elapsed from session/turn start.** Rejected; the Time column is each row's own duration.
|
||||
|
||||
**Hang usage on the first expanded row.** Rejected; usage attaches to Message only.
|
||||
|
||||
**Show in-flight tool durations via Date.now().** Deferred; in-flight Time stays `—`.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Trajectory tab can render expanded finalized and in-flight rows with own-duration times once fold emits `time`. Behavior-shaped coverage lives in `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`. Chat selection deep-links and finer block-level clocks remain deferred.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Trajectory 步骤单元格与轮次列表 chrome
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-23-trajectory-step-cell.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展示展开后的 assistant 块、自身耗时、Message token 列,以及进行中的工作。若不将会话事件时间折叠进会话节点,并将块展开为单元格,UI 就无法对齐产品 chrome。
|
||||
|
||||
## Decision
|
||||
|
||||
[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) 拥有展示型 trajectory 列表 chrome:
|
||||
|
||||
- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。
|
||||
- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。
|
||||
- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上(含无 text 块时的空回退行),并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。`user/message` 无线上 turn,故每条 User 行归入下一 assistant/steering 的 turn,否则归入进行中的 `partial` turn,否则为 `lastAssistantTurn + 1`(或 `1`)。context 节点不产出单元格,但仍推进 Message 耗时游标。
|
||||
|
||||
[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间(含跳过的 context);Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**为 reasoning 块保留 Think 单元格。** 否决:单条 `assistant/message.time` 无法给出 Think 自身耗时(除非上 chunk 级时钟);与其显示 `—`,不如省略该行。
|
||||
|
||||
**保留分开的 Call 与 Result 行。** 否决:Result 没有可展示的自身耗时;一行 Tool 承载 call→result 区间。
|
||||
|
||||
**自会话/轮次起点累计耗时。** 否决;Time 列是每行自身的耗时。
|
||||
|
||||
**将用量挂在展开后的第一行。** 否决;用量仅附着于 Message。
|
||||
|
||||
**用 Date.now() 显示进行中工具的耗时。** 延后;进行中的 Time 保持为 `—`。
|
||||
|
||||
## Consequences
|
||||
|
||||
一旦 fold 发出 `time`,Trajectory 标签页即可渲染带自身耗时的已定稿与进行中展开行。行为导向的覆盖位于 `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`。chat 选中深链与更细的块级时钟仍延后。
|
||||
@@ -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
|
||||
2026-07-24-new-session-clears-to-empty-state.md: 1605f44a05d0f59b61fe95cb5b03a0f9f5c3d4ab
|
||||
2026-07-24-new-session-clears-to-empty-state.zh.md: 1f78d99babc33d30ee1300bfa6bf048a78e7132e
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: New Session clears onto the empty-state launch
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-24-new-session-clears-to-empty-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Sidebar "New Session" created and opened a blank session immediately, so the center column showed `ConversationRoot` with an empty transcript and the resident composer. The Figma NEW SESSION screen (`EmptyState` + shared `InputBar` hero) only rendered when `sessions.current` was already undefined, so the launch page was unreachable from the primary creation control.
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionsService.clear()` wipes the persisted selection and `list.current`. Top-level sidebar creation entries (`onCreate()` with no cwd — New Session and New Workspace) call `clear()` so `AppFrame` renders `conversation.empty`. The empty state's first send still runs `conversation.startSession` (create → open → send) and reuses the same `InputBar` component as the resident composer (`variant="hero"`). Per-project "+" (`onCreate(cwd)`) keeps create-then-open until the empty-state picker can accept a seeded cwd.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep create-then-open for New Session and add a second empty chrome inside ConversationRoot when the transcript is empty.** Rejected: that duplicates the launch InputBar and breaks the empty→content ruling that one InputBar moves position rather than swapping components.
|
||||
|
||||
**Route New Session through a dedicated route or slot outside selection.** Rejected for this pass: `conversation.empty` already owns the launch UI; clearing `current` is the existing empty branch.
|
||||
|
||||
## Consequences
|
||||
|
||||
New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero (Input_Bottom 75:8208) as fish + title, a Menu-backed workspace chip above the card, then shared `InputBar` (`variant="hero"`, max-width 800, r20 card matching the composer — not a taller r24 hero), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776` asset ratio) so it scales with it. The chip uses the soft interactive hover fill + 12px radius from 75:8208 and opens MenuDropdown (figma 122:9481; `--dsw-specific-menu` + `--dsw-shadow-lv3`): basename rows with folder icons and a trailing check, then a separator and "New Workspace" whose submenu (figma 419:16920) offers "Use a existing folder" and "Create new". Use a existing folder opens the path Dialog (figma 451:18655 copy — "Enter an existing folder path" / Open Folder) over a full-viewport mask (`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`) and sets the chip cwd. Create new opens the same Dialog chrome to name a folder under `host.describe().cwd`; success runs `sessions.createWorkspace` → host `session.create` (mkdir recursive) → `sessions.open`, so a default session lands in the new workspace. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `<select>` state only — host plan, access, and model seams remain unwired.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Agent Note: New Session clears onto the empty-state launch
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-24-new-session-clears-to-empty-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
侧栏「New Session」会立即创建并打开空白会话,因此中间栏显示带空 transcript(文本记录)与常驻 composer 的 `ConversationRoot`。Figma 的 NEW SESSION 屏(`EmptyState` + 共用的 `InputBar` hero)仅在 `sessions.current` 已为 undefined 时渲染,因而主创建控件无法到达启动页。
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionsService.clear()` 清除持久化选中项与 `list.current`。顶层侧栏创建入口(无 cwd 的 `onCreate()`——New Session 与 New Workspace)调用 `clear()`,使 `AppFrame` 渲染 `conversation.empty`。空态的首次发送仍走 `conversation.startSession`(create → open → send),并复用与常驻 composer 相同的 `InputBar` 组件(`variant="hero"`)。按项目的「+」(`onCreate(cwd)`)继续 create-then-open,直到空态选择器能接受预填的 cwd。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**为 New Session 保留 create-then-open,并在 transcript 为空时于 ConversationRoot 内再加一套空态 chrome。** 否决:这会重复启动页的 InputBar,并破坏 empty→content 的约定——同一 InputBar 应移动位置,而非互换组件。
|
||||
|
||||
**将 New Session 路由到选中状态之外的专用 route 或 slot。** 本轮否决:`conversation.empty` 已拥有启动 UI;清除 `current` 即是既有的空态分支。
|
||||
|
||||
## Consequences
|
||||
|
||||
New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区(Input_Bottom 75:8208):鱼标 + 标题、卡片上方由 Menu 驱动的工作区 chip,再接共用的 `InputBar`(`variant="hero"`,max-width 800,与 composer 一致的 r20 卡片——而非更高的 r24 英雄区),选择器与卡片背后居中铺一层柔光椭圆(figma 313:14109),宽度按卡片锁定为 `1051/776` asset 比例,随卡片缩放。Chip 采用 75:8208 的柔和交互 hover 填充与 12px 圆角,并打开 MenuDropdown(figma 122:9481;`--dsw-specific-menu` + `--dsw-shadow-lv3`):带文件夹图标与尾随勾选的 basename 行,分隔线后是 "New Workspace",其子菜单(figma 419:16920)提供 "Use a existing folder" 与 "Create new"。Use a existing folder 打开路径 Dialog(figma 451:18655 copy — "Enter an existing folder path" / Open Folder),置于全视口遮罩(`--dsw-alias-bg-mask-1` + `--dsw-mask-blur`)之上,并设置 chip 的 cwd。Create new 打开同一套 Dialog chrome,在 `host.describe().cwd` 下命名文件夹;成功则走 `sessions.createWorkspace` → host `session.create`(mkdir recursive)→ `sessions.open`,在新 workspace 中默认落下一会话。`InputBar` 绘制底栏 chrome(attach / Plan / Read-only / model),仅用本地原生 `<select>` 状态——host 侧的 plan、access、model 接缝仍未接线。
|
||||
@@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock {
|
||||
export interface UserMessageNode {
|
||||
kind: 'user'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
}
|
||||
@@ -50,6 +52,8 @@ export interface UserMessageNode {
|
||||
export interface AssistantMessageNode {
|
||||
kind: 'assistant'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */
|
||||
time: number
|
||||
turn: number
|
||||
step: number
|
||||
blocks: readonly AssistantBlock[]
|
||||
@@ -63,6 +67,8 @@ export interface AssistantMessageNode {
|
||||
export interface SteeringMessageNode {
|
||||
kind: 'steering'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
turn: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
@@ -72,6 +78,8 @@ export interface SteeringMessageNode {
|
||||
export interface ContextMessageNode {
|
||||
kind: 'context'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event. */
|
||||
time: number
|
||||
content: readonly ContentBlock[]
|
||||
source: unknown
|
||||
meta?: unknown
|
||||
@@ -81,9 +89,13 @@ export interface ContextMessageNode {
|
||||
export interface ToolResultNode {
|
||||
kind: 'tool-result'
|
||||
seq: number
|
||||
/** Unix epoch ms from the tool/result session event. */
|
||||
time: number
|
||||
callId: string
|
||||
/** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */
|
||||
call: { name: string; argsRaw: string } | null
|
||||
/** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */
|
||||
callTime: number | null
|
||||
content: readonly ContentBlock[]
|
||||
isError: boolean
|
||||
error?: { name: string; code: string }
|
||||
@@ -98,6 +110,8 @@ export interface ToolResultNode {
|
||||
export interface UnknownSurfaceNode {
|
||||
kind: 'unknown'
|
||||
seq: number
|
||||
/** Unix epoch ms from the source session event when known. */
|
||||
time: number
|
||||
type: string
|
||||
data: unknown
|
||||
}
|
||||
@@ -118,6 +132,8 @@ export interface RunningToolCall {
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms when the tool/call event was logged. */
|
||||
time: number
|
||||
/** Host-computed render intent riding the tool/call frame; null = generic JSON card. */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface CallIndexEntry {
|
||||
argsRaw: string
|
||||
turn: number
|
||||
step: number
|
||||
/** Unix epoch ms of the tool/call event. */
|
||||
time: number
|
||||
/** Wire view riding the tool/call (envelope-level; never inside the event). */
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
@@ -42,23 +44,33 @@ function materializeNode(
|
||||
// user message; only a direct human prompt is a user node.
|
||||
if (event.data.source.kind !== 'user') {
|
||||
return {
|
||||
kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source,
|
||||
kind: 'context', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
meta: event.data.meta,
|
||||
}
|
||||
}
|
||||
return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source }
|
||||
return {
|
||||
kind: 'user', seq: event.seq, time: event.time,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'assistant/message':
|
||||
return {
|
||||
kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step,
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
||||
}
|
||||
case 'steering/message':
|
||||
return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source }
|
||||
return {
|
||||
kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn,
|
||||
content: event.data.content, source: event.data.source,
|
||||
}
|
||||
case 'tool/result': {
|
||||
const call = callIndex.get(String(event.data.callId))
|
||||
return {
|
||||
kind: 'tool-result', seq: event.seq, callId: String(event.data.callId),
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: String(event.data.callId),
|
||||
call: call ? { name: call.name, argsRaw: call.argsRaw } : null,
|
||||
callTime: call?.time ?? null,
|
||||
content: event.data.content, isError: event.data.isError,
|
||||
...(event.data.error !== undefined ? { error: event.data.error } : {}),
|
||||
meta: event.data.meta,
|
||||
@@ -70,7 +82,10 @@ function materializeNode(
|
||||
surface-eligible types, and each has a case above; reachable only if core
|
||||
adds an eligible type. */
|
||||
default:
|
||||
return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data }
|
||||
return {
|
||||
kind: 'unknown', seq: event.seq, time: event.time,
|
||||
type: event.type, data: (event as { data?: unknown }).data,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +204,7 @@ export class FoldAdapter {
|
||||
if (event.type !== 'tool/call') return
|
||||
this.callIdx.set(String(event.data.callId), {
|
||||
name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step,
|
||||
time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
// No backfill into already-materialized tool-result nodes for this callId
|
||||
|
||||
@@ -95,9 +95,10 @@ export class SessionsService {
|
||||
/**
|
||||
* Persisted selection cell (the durable half of `list.current`). Private on
|
||||
* purpose: reads go through the list snapshot; writes through {@link
|
||||
* SessionsService.open}. Projection validates it against the live list
|
||||
* instead of destructively pruning, so a selection survives transient list
|
||||
* states (reconnect re-pull) and resurfaces when its session returns.
|
||||
* SessionsService.open} / {@link SessionsService.clear}. Projection
|
||||
* validates it against the live list instead of destructively pruning, so a
|
||||
* selection survives transient list states (reconnect re-pull) and
|
||||
* resurfaces when its session returns.
|
||||
*/
|
||||
private readonly selection: SnapshotStore<{ sessionId?: SessionId }>
|
||||
|
||||
@@ -116,7 +117,7 @@ export class SessionsService {
|
||||
* @param ctx - client root context (scope fibers mount under it).
|
||||
* @param api - wire client shared with every Session.
|
||||
*/
|
||||
constructor(private readonly rootCtx: Context, api: IApiClient) {
|
||||
constructor(private readonly rootCtx: Context, private readonly api: IApiClient) {
|
||||
this.manager = new SessionManager(api)
|
||||
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
|
||||
{},
|
||||
@@ -137,7 +138,7 @@ export class SessionsService {
|
||||
|
||||
/**
|
||||
* Select a session as current. Unknown ids fail loud instead of navigating
|
||||
* nowhere (the sole selection write path).
|
||||
* nowhere.
|
||||
* @param id - session id (must exist in the list store).
|
||||
*/
|
||||
open(id: SessionId): void {
|
||||
@@ -148,6 +149,17 @@ export class SessionsService {
|
||||
this.list.update((draft) => { draft.current = id })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current selection so the layout shows the no-session empty
|
||||
* state. Wipes the persisted selection too — a reload stays on empty until
|
||||
* the user opens or starts a session. Staging holds the previous occupant
|
||||
* across the blank (same masked-gap rule as a transient list miss).
|
||||
*/
|
||||
clear(): void {
|
||||
this.selection.set({})
|
||||
this.list.update((draft) => { draft.current = undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session on the host.
|
||||
* @param opts - creation options (project directory).
|
||||
@@ -159,6 +171,27 @@ export class SessionsService {
|
||||
return result.value.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace folder under the host process cwd and a session in it.
|
||||
* Name is a single path segment (no separators); the host mkdir runs inside
|
||||
* session.create. Caller opens the returned id when it wants the session staged.
|
||||
* @param name - workspace folder basename.
|
||||
* @returns the new session id.
|
||||
*/
|
||||
async createWorkspace(name: string): Promise<SessionId> {
|
||||
const trimmed = name.trim()
|
||||
if (trimmed === '') throw new Error('sessions.createWorkspace: name is required')
|
||||
if (/[/\\]/.test(trimmed)) {
|
||||
throw new Error('sessions.createWorkspace: name must not contain path separators')
|
||||
}
|
||||
const { result } = await this.api.host.describe({})
|
||||
if (!result.ok) {
|
||||
throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
const hostCwd = result.value.cwd.replace(/[/\\]+$/, '')
|
||||
return this.create({ cwd: `${hostCwd}/${trimmed}` })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a session-scoped context view (use-and-discard).
|
||||
* @param id - session id.
|
||||
|
||||
@@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
case 'tool/call': {
|
||||
this.openCalls.set(String(event.data.callId), {
|
||||
callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
turn: event.data.turn, step: event.data.step, time: event.time,
|
||||
callView: view?.for === 'call' ? view.view : null,
|
||||
})
|
||||
this.callsRev++
|
||||
@@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
if (visible) {
|
||||
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
|
||||
this.frozenNodes.push({
|
||||
kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step,
|
||||
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
|
||||
turn: this.partial.turn, step: this.partial.step,
|
||||
blocks, interrupted: true,
|
||||
})
|
||||
this.frozenRev++
|
||||
@@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.callsRev++
|
||||
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
|
||||
this.frozenNodes.push({
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId,
|
||||
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
|
||||
callId,
|
||||
call: { name: call.name, argsRaw: call.argsRaw },
|
||||
callTime: call.time,
|
||||
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
|
||||
callView: call.callView, resultView: null,
|
||||
})
|
||||
|
||||
@@ -133,6 +133,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
|
||||
expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone
|
||||
})
|
||||
|
||||
it('clear() blanks list.current and the persisted selection', async () => {
|
||||
const storage = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => storage.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => { storage.set(k, v) },
|
||||
removeItem: (k: string) => { storage.delete(k) },
|
||||
clear: () => { storage.clear() },
|
||||
})
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
b.svc.open(sid('s1'))
|
||||
expect(storage.get('dsh.sessions.current')).toContain('s1')
|
||||
b.svc.clear()
|
||||
expect(b.svc.list.getSnapshot().current).toBeUndefined()
|
||||
// Persisted wipe: a fresh service with the same storage stays on empty.
|
||||
const again = bench()
|
||||
await feedList(again, [{ id: 's1' }])
|
||||
expect(again.svc.list.getSnapshot().current).toBeUndefined()
|
||||
})
|
||||
|
||||
it('masks (not destroys) the selection while its session is off the list', async () => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }, { id: 's2' }])
|
||||
@@ -277,6 +297,27 @@ describe('create', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorkspace', () => {
|
||||
it('joins host.describe cwd with the name and creates there', async () => {
|
||||
const b = bench()
|
||||
b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 }))
|
||||
b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') }))
|
||||
await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws')
|
||||
expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }])
|
||||
})
|
||||
|
||||
it('rejects empty names and path separators; surfaces describe failures', async () => {
|
||||
const b = bench()
|
||||
await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/)
|
||||
await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/)
|
||||
b.api.onDescribe = () => Promise.resolve({
|
||||
rpcId: 'e' as never,
|
||||
result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } },
|
||||
} as never)
|
||||
await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('coverage tails (branch duals)', () => {
|
||||
it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => {
|
||||
const b = bench()
|
||||
|
||||
@@ -160,6 +160,10 @@ export function apply(ctx: Context): void {
|
||||
if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
|
||||
return conversation.startSession(opts)
|
||||
},
|
||||
createWorkspaceSession: async (name) => {
|
||||
const id = await sessions.createWorkspace(name)
|
||||
sessions.open(id)
|
||||
},
|
||||
}),
|
||||
}, EmptyState)
|
||||
}
|
||||
|
||||
@@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
export interface EmptyStateInjected {
|
||||
/** The create → navigate → first-send chain, in one service call. */
|
||||
startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise<void>
|
||||
/**
|
||||
* Create a workspace folder under the host cwd, mint a session there, and
|
||||
* open it (Create-new modal success path).
|
||||
*/
|
||||
createWorkspaceSession(name: string): Promise<void>
|
||||
}
|
||||
|
||||
/** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* NEW SESSION hero: headline over the shared InputBar card, centered in the
|
||||
conversation column. The card is the same component as the composer —
|
||||
only positioning lives here. */
|
||||
/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip
|
||||
above the shared InputBar card. The input itself is InputBar — only stack
|
||||
geometry and the chip live here. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
@@ -11,58 +11,154 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* figma hero group 34:10409: headline block sits 36px above the input card. */
|
||||
.card {
|
||||
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 36px;
|
||||
align-items: stretch;
|
||||
/* figma 75:8208: 12 between title block / workspace / card. */
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
max-width: 800px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
|
||||
keeps 36px below the headline before the flex gap. */
|
||||
.headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* figma 34:10412/10413: brand-blue vector. */
|
||||
/* figma fish fill rides business blue. */
|
||||
.fish {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.picker {
|
||||
/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is
|
||||
centered on this block so it stays under the picker + InputBar together. */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */
|
||||
.glow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 0;
|
||||
width: calc(100% * 1051 / 776);
|
||||
aspect-ratio: 1051 / 468;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.body > :not(.glow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its
|
||||
right-hand submenu) paints above the InputBar card. */
|
||||
.body > .workspaceRow {
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
/* figma 75:8208 workspace row: px 8 above the card. */
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.select,
|
||||
.customInput {
|
||||
max-width: 320px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
/* Folder + label + chevron — transparent at rest; fill only on hover / open. */
|
||||
.workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.customInput {
|
||||
width: 320px;
|
||||
outline: none;
|
||||
.workspace:hover,
|
||||
.workspace[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.customInput:focus {
|
||||
/* Business blue, not brand-primary: that token resolves to ink in this sheet. */
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
.folder {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.workspaceLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Workspace menu width tracks the longest basename in the Figma frame. */
|
||||
.workspaceMenu :global([role='menu']) {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */
|
||||
.modalInput {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 22px;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.modalInput::placeholder {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.modalInput:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.modalInput:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.modalError {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
// EmptyState (figma NEW SESSION screen): centered hero card built around the
|
||||
// SAME InputBar component the resident composer uses (the empty→content
|
||||
// transition is one component changing position, never a swap). Project
|
||||
// picker: cwd set derived in-component from the standard useSessions hook
|
||||
// (subscription is the framework's, derivation is a pure function — design
|
||||
// §6) plus a free-form new-directory input; submit runs the startSession
|
||||
// chain (create → open → send) in one service call.
|
||||
// EmptyState (figma NEW SESSION screen): centered hero — fish + title,
|
||||
// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu
|
||||
// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident
|
||||
// composer uses (empty→content is a position move, never a swap). Project
|
||||
// options derive in-component from useSessions; Create new runs
|
||||
// createWorkspaceSession (host mkdir + session.create + open).
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
FishLogo,
|
||||
IconChevronDownOutline14,
|
||||
IconFolderClose16,
|
||||
IconFolderOpen16,
|
||||
IconPlusOutline16,
|
||||
Menu,
|
||||
Modal,
|
||||
type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { EmptyStateSlotProps } from '../contract/slots.ts'
|
||||
import { InputBar } from './InputBar.tsx'
|
||||
import type { InputBarError } from './InputBar.tsx'
|
||||
import css from './EmptyState.module.css'
|
||||
|
||||
/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */
|
||||
const NEW_DIR = '::new-directory'
|
||||
/** Menu id for "New Workspace" (opens submenu; not a cwd). */
|
||||
const NEW_WORKSPACE = '::new-workspace'
|
||||
/** Submenu: path modal (figma 451:18655 copy). */
|
||||
const USE_EXISTING = '::use-existing'
|
||||
/** Submenu: create-workspace modal → mkdir + default session. */
|
||||
const CREATE_NEW = '::create-new'
|
||||
|
||||
/** Which full-page dialog is open (null = none). */
|
||||
type ModalKind = 'path' | 'create' | null
|
||||
|
||||
/** Full props composed by reference from the contract (runtime share & injected share; no store). */
|
||||
export type EmptyStateProps = EmptyStateSlotProps
|
||||
@@ -30,16 +46,30 @@ function deriveCwds(state: SessionListState): readonly string[] {
|
||||
return [...seen]
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */
|
||||
function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) {
|
||||
const list = useSessions(s => s)
|
||||
const cwds = useMemo(() => deriveCwds(list), [list])
|
||||
// Local viewing state: the empty state owns no session, so its draft is
|
||||
// ephemeral by design (drafts are keyed by session id; there is none yet).
|
||||
const [draft, setDraft] = useState('')
|
||||
const [cwd, setCwd] = useState<string>('')
|
||||
const [custom, setCustom] = useState(false)
|
||||
const [cwd, setCwd] = useState('')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [modalKind, setModalKind] = useState<ModalKind>(null)
|
||||
const [pathDraft, setPathDraft] = useState('')
|
||||
const [workspaceName, setWorkspaceName] = useState('New WorkSpace')
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [error, setError] = useState<InputBarError | null>(null)
|
||||
// Stable filter id so multiple EmptyState mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
|
||||
const submit = (mode: 'queue' | 'steer'): void => {
|
||||
const text = draft.trim()
|
||||
@@ -58,62 +88,218 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
|
||||
// Success needs no cleanup: the session selection swaps this slot out for the session body.
|
||||
}
|
||||
|
||||
const picker = (
|
||||
<div className={css.picker}>
|
||||
{custom
|
||||
? (
|
||||
<input
|
||||
className={css.customInput}
|
||||
value={cwd}
|
||||
autoFocus
|
||||
placeholder="目录路径,如 /home/me/proj"
|
||||
onChange={(e) => { setCwd(e.target.value) }}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<select
|
||||
className={css.select}
|
||||
value={cwd}
|
||||
aria-label="项目目录"
|
||||
onChange={(e) => {
|
||||
if (e.target.value === NEW_DIR) {
|
||||
setCustom(true)
|
||||
setCwd('')
|
||||
} else {
|
||||
setCwd(e.target.value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">默认目录</option>
|
||||
{cwds.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
<option value={NEW_DIR}>新目录…</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const items: MenuEntry[] = [
|
||||
...cwds.map(c => ({
|
||||
id: c,
|
||||
label: workspaceLabel(c),
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
})),
|
||||
...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []),
|
||||
{
|
||||
id: NEW_WORKSPACE,
|
||||
label: 'New Workspace',
|
||||
icon: <IconPlusOutline16 size={16} />,
|
||||
submenu: [
|
||||
{ id: USE_EXISTING, label: 'Use a existing folder' },
|
||||
{ id: CREATE_NEW, label: 'Create new' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating) return
|
||||
setModalKind(null)
|
||||
setModalError(null)
|
||||
}
|
||||
|
||||
const openPathModal = (): void => {
|
||||
setPathDraft(cwd)
|
||||
setModalError(null)
|
||||
setModalKind('path')
|
||||
}
|
||||
|
||||
const openCreateModal = (): void => {
|
||||
setWorkspaceName('New WorkSpace')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
}
|
||||
|
||||
const confirmPath = (): void => {
|
||||
const next = pathDraft.trim()
|
||||
if (next === '') return
|
||||
setCwd(next)
|
||||
setModalKind(null)
|
||||
}
|
||||
|
||||
const confirmCreate = (): void => {
|
||||
if (creating) return
|
||||
setCreating(true)
|
||||
setModalError(null)
|
||||
createWorkspaceSession(workspaceName)
|
||||
.catch((reason: unknown) => {
|
||||
setModalError(reason instanceof Error ? reason.message : String(reason))
|
||||
setCreating(false)
|
||||
})
|
||||
// Success swaps this slot out for the new session body — no local cleanup.
|
||||
}
|
||||
|
||||
const modalBusy = creating
|
||||
const isPath = modalKind === 'path'
|
||||
const isCreate = modalKind === 'create'
|
||||
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.card}>
|
||||
<div className={css.stack}>
|
||||
<div className={css.headline}>
|
||||
{/* figma 34:10412: fish 34x25 leading the headline, gap 10. */}
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<FishLogo size={34} className={css.fish} />
|
||||
Let's start building
|
||||
</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build"
|
||||
accessory={picker}
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width
|
||||
tracks the card (glow asset 1051 vs design card 776) so blur
|
||||
scales in userSpace with it. */}
|
||||
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<filter
|
||||
id={glowFilterId}
|
||||
x="0"
|
||||
y="0"
|
||||
width="1051"
|
||||
height="468"
|
||||
filterUnits="userSpaceOnUse"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feFlood floodOpacity="0" result="BackgroundImageFix" />
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
<g filter={`url(#${glowFilterId})`}>
|
||||
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
|
||||
</g>
|
||||
</svg>
|
||||
<div className={css.workspaceRow}>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
{...(cwd !== '' ? { selectedId: cwd } : {})}
|
||||
items={items}
|
||||
side="top"
|
||||
className={css.workspaceMenu!}
|
||||
onSelect={(id) => {
|
||||
if (id === USE_EXISTING) {
|
||||
setMenuOpen(false)
|
||||
openPathModal()
|
||||
return
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
setMenuOpen(false)
|
||||
openCreateModal()
|
||||
return
|
||||
}
|
||||
setCwd(id)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.workspace}
|
||||
aria-label="项目目录"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => { setMenuOpen(!menuOpen) }}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{workspaceLabel(cwd)}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<InputBar
|
||||
draft={draft}
|
||||
running={false}
|
||||
disabled={sending}
|
||||
error={error}
|
||||
variant="hero"
|
||||
placeholder="Message to run task, plan and build, enter for / commands"
|
||||
onDraftChange={setDraft}
|
||||
onSend={submit}
|
||||
/* v8 ignore next -- structural noop: hero never passes running=true, so stop is unreachable. */
|
||||
onStop={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
open={isPath}
|
||||
onClose={closeModal}
|
||||
title="Enter an existing folder path"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} onClick={closeModal}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={pathDraft.trim() === ''}
|
||||
onClick={confirmPath}
|
||||
>
|
||||
Open Folder
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={pathDraft}
|
||||
aria-label="Folder path"
|
||||
autoFocus
|
||||
placeholder="ex. User/Documents/Harness/Space"
|
||||
onChange={(e) => { setPathDraft(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmPath()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isCreate}
|
||||
onClose={closeModal}
|
||||
title="Create new workspace"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction!} disabled={modalBusy} onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.modalAction!}
|
||||
disabled={modalBusy || workspaceName.trim() === ''}
|
||||
onClick={confirmCreate}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={workspaceName}
|
||||
aria-label="Workspace name"
|
||||
autoFocus
|
||||
disabled={modalBusy}
|
||||
onChange={(e) => { setWorkspaceName(e.target.value) }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the
|
||||
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
|
||||
viewport bottom inside the centered message column; textarea on top, action
|
||||
row below, one primary circle button bottom-right. Input width rides the
|
||||
column (776 is a cap, not a fixed size — layout rule: the box shrinks with
|
||||
column (800 is a cap, not a fixed size — layout rule: the box shrinks with
|
||||
the center column keeping its padding). Hero variant = the same card
|
||||
centered in the empty state; the transition between the two is a position
|
||||
move of one component. */
|
||||
@@ -10,8 +10,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is
|
||||
owned by the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||
the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||
padding: 8px 32px 12px;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
.error {
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
max-width: 800px;
|
||||
margin-bottom: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
@@ -34,10 +34,12 @@
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* figma Input 34:11458: 12px between the text area and the button row. */
|
||||
/* figma Input 75:8208: 12px between the text area and the button row; 10px
|
||||
top pad on the card before .InputText. */
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
max-width: 800px;
|
||||
padding-top: 10px;
|
||||
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
|
||||
the input border is one notch weaker than buttons) — exactly the
|
||||
l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */
|
||||
@@ -49,11 +51,6 @@
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* New-session state rounds up (figma: r24 and a taller box). */
|
||||
.hero .card {
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.accessory {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -85,7 +82,8 @@
|
||||
|
||||
.input,
|
||||
.mirror {
|
||||
padding: 12px 16px 0;
|
||||
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
|
||||
padding: 4px 12px 0 16px;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
white-space: pre-wrap;
|
||||
@@ -108,23 +106,98 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
/* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */
|
||||
min-height: 60px;
|
||||
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
|
||||
min-height: 52px;
|
||||
max-height: 336px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero .mirror {
|
||||
/* New-session box is taller at rest (figma 118px input area). */
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */
|
||||
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
|
||||
(figma Input_Bottom chrome). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 10px 10px 12px;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 10px 10px 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tools,
|
||||
.modes,
|
||||
.trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */
|
||||
.tools {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.modes {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
flex: none;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Attach circle (figma + control): 28px, selector fill, primary glyph. */
|
||||
.add {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-specific-selector);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-solid);
|
||||
}
|
||||
|
||||
.add:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Plan / Read-only / model — native <select>, chip-like closed chrome
|
||||
(figma ToggleButton: 13/20 medium secondary, 12px chevron). */
|
||||
.select {
|
||||
max-width: 220px;
|
||||
height: 28px;
|
||||
padding: 0 20px 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background-color: transparent;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 4px center;
|
||||
background-size: 12px 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.select:hover:not(:disabled) {
|
||||
background-color: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Primary send (figma IconButton 34:10465): 34px circle, #3964FE light /
|
||||
@@ -133,6 +206,7 @@
|
||||
.primary {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
// position move of this component, never a swap (layout ruling). Running
|
||||
// LOCKS the input: textarea disabled with the draft visible, stop is the only
|
||||
// action; the turn ending re-enables and refocuses.
|
||||
//
|
||||
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
|
||||
// local native <select> state, no host wiring.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './InputBar.module.css'
|
||||
|
||||
/** Prompt failure surface (mirrors the session snapshot's promptError shape). */
|
||||
@@ -24,13 +28,33 @@ export interface InputBarProps {
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
/** Optional leading accessory row content (the empty state mounts its cwd picker here). */
|
||||
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const PLAN_OPTIONS: readonly SelectOption[] = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'agent', label: 'Agent' },
|
||||
]
|
||||
|
||||
const READONLY_OPTIONS: readonly SelectOption[] = [
|
||||
{ id: 'readonly', label: 'Read-only' },
|
||||
{ id: 'readwrite', label: 'Read-write' },
|
||||
]
|
||||
|
||||
const MODEL_OPTIONS: readonly SelectOption[] = [
|
||||
{ id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' },
|
||||
{ id: 'v4-pro', label: 'DeepSeek-V4-Pro' },
|
||||
]
|
||||
|
||||
export function InputBar({
|
||||
draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop,
|
||||
}: InputBarProps) {
|
||||
@@ -48,6 +72,11 @@ export function InputBar({
|
||||
}, 10)
|
||||
}
|
||||
|
||||
// Placeholder chrome: selection is local until plan/mode/model seams land.
|
||||
const [planId, setPlanId] = useState('plan')
|
||||
const [readonlyId, setReadonlyId] = useState('readonly')
|
||||
const [modelId, setModelId] = useState('v4-pro-high')
|
||||
|
||||
// Locked while running: the browser drops keystrokes AND focus on a disabled
|
||||
// textarea — no sending mid-turn, stop or wait.
|
||||
const locked = disabled || running
|
||||
@@ -88,6 +117,25 @@ export function InputBar({
|
||||
if (!empty && !disabled) onSend('queue')
|
||||
}
|
||||
|
||||
const renderSelect = (
|
||||
aria: string,
|
||||
value: string,
|
||||
options: readonly SelectOption[],
|
||||
onPick: (id: string) => void,
|
||||
): ReactNode => (
|
||||
<select
|
||||
className={css.select}
|
||||
aria-label={aria}
|
||||
value={value}
|
||||
disabled={locked}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) => { onPick(e.target.value) }}
|
||||
>
|
||||
{options.map(opt => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, variant === 'hero' && css.hero)}>
|
||||
{error !== null && (
|
||||
@@ -116,25 +164,44 @@ export function InputBar({
|
||||
<div aria-hidden className={css.mirror}>{`${draft}\n`}</div>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
aria-label={primaryLabel}
|
||||
title={running ? '停止本轮' : '发送(Enter)'}
|
||||
disabled={!running && (empty || disabled)}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{running ? (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
|
||||
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className={css.tools}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.add}
|
||||
aria-label="添加"
|
||||
title="添加"
|
||||
disabled={locked}
|
||||
onMouseDown={keepFocus}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
</button>
|
||||
<div className={css.modes}>
|
||||
{renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)}
|
||||
{renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
aria-label={primaryLabel}
|
||||
title={running ? '停止本轮' : '发送(Enter)'}
|
||||
disabled={!running && (empty || disabled)}
|
||||
onMouseDown={keepFocus}
|
||||
onClick={onPrimary}
|
||||
>
|
||||
{running ? (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
|
||||
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,7 @@ async function bench() {
|
||||
cell: () => undefined,
|
||||
scopeOf,
|
||||
create: vi.fn(() => Promise.resolve(ROOT)),
|
||||
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
@@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => {
|
||||
expect(details).toBe(conv)
|
||||
})
|
||||
|
||||
it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => {
|
||||
it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('conversation.empty')
|
||||
expect(entry.store).toBeUndefined()
|
||||
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession'])
|
||||
expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession'])
|
||||
await injected.startSession({ text: 'go', mode: 'queue' })
|
||||
expect(b.sessionsFake.create).toHaveBeenCalled()
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
|
||||
b.sessionsFake.open.mockClear()
|
||||
await injected.createWorkspaceSession('Fresh')
|
||||
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
})
|
||||
|
||||
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ afterEach(cleanup)
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('deriveStats', () => {
|
||||
|
||||
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [],
|
||||
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
@@ -112,8 +112,9 @@ describe('bash sample row', () => {
|
||||
const CHILD = 'child-1' as SessionId
|
||||
|
||||
const result = (callId: string): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 3, callId,
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId,
|
||||
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
|
||||
callTime: 2_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/
|
||||
|
||||
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
|
||||
turn: 1, step: 1, callView: null, ...over,
|
||||
turn: 1, step: 1, time: 1_000, callView: null, ...over,
|
||||
})
|
||||
|
||||
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
|
||||
kind: 'tool-result', seq: 10, callId: 'c1',
|
||||
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
|
||||
callTime: 1_000,
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, callId,
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: args },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
|
||||
|
||||
@@ -54,18 +54,19 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
|
||||
const user = (seq: number, text: string): UserMessageNode => ({
|
||||
kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null,
|
||||
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
|
||||
})
|
||||
const assistant = (seq: number, text: string): AssistantMessageNode => ({
|
||||
kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
|
||||
})
|
||||
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
|
||||
kind: 'tool-result', seq, callId,
|
||||
kind: 'tool-result', seq, time: seq * 1_000, callId,
|
||||
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
|
||||
callTime: seq * 1_000 - 500,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
})
|
||||
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
|
||||
})
|
||||
|
||||
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
|
||||
|
||||
@@ -62,8 +62,9 @@ describe('tails', () => {
|
||||
|
||||
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
|
||||
const settled: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 2, callId: 'c5',
|
||||
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
|
||||
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
|
||||
callTime: 1_000,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolRowOwnerProps = {
|
||||
@@ -77,8 +78,9 @@ describe('tails', () => {
|
||||
|
||||
it('BashRow shows the failed pill on error results (root session arm)', () => {
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, callId: 'c1',
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
// Root session (no parentId): the global arm renders, error pill visible.
|
||||
|
||||
@@ -19,7 +19,10 @@ function setup(over?: Partial<InputBarProps>) {
|
||||
}
|
||||
const view = render(<InputBar {...props} />)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
const button = view.container.querySelector('button')!
|
||||
// aria-label (not role name): title also contains 发送/停止 and would double-match.
|
||||
const button = view.container.querySelector<HTMLButtonElement>(
|
||||
`button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
|
||||
)!
|
||||
return { view, textarea, button, props }
|
||||
}
|
||||
|
||||
@@ -97,7 +100,7 @@ describe('running lock and primary button', () => {
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
textarea.blur()
|
||||
fireEvent.mouseDown(view.container.querySelector('button')!)
|
||||
fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
@@ -129,3 +132,38 @@ describe('error strip and variants', () => {
|
||||
expect(view.container.querySelector('[class*="hero"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('placeholder chrome', () => {
|
||||
it('renders attach / Plan / Read-only / model controls', () => {
|
||||
const { view } = setup()
|
||||
expect(view.getByLabelText('添加')).toBeTruthy()
|
||||
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
|
||||
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
|
||||
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
|
||||
})
|
||||
|
||||
it('native select change updates the selected option', () => {
|
||||
const { view } = setup()
|
||||
const plan = view.getByLabelText('Plan mode') as HTMLSelectElement
|
||||
fireEvent.change(plan, { target: { value: 'agent' } })
|
||||
expect(plan.value).toBe('agent')
|
||||
const access = view.getByLabelText('Access mode') as HTMLSelectElement
|
||||
fireEvent.change(access, { target: { value: 'readwrite' } })
|
||||
expect(access.value).toBe('readwrite')
|
||||
})
|
||||
|
||||
it('model select can drop the High option', () => {
|
||||
const { view } = setup()
|
||||
const model = view.getByLabelText('Model') as HTMLSelectElement
|
||||
fireEvent.change(model, { target: { value: 'v4-pro' } })
|
||||
expect(model.value).toBe('v4-pro')
|
||||
expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro')
|
||||
})
|
||||
|
||||
it('running locks the chrome selects and attach control', () => {
|
||||
const { view } = setup({ running: true })
|
||||
expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
|
||||
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
|
||||
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
|
||||
// result blocks / error-only results over the shared store, EmptyState
|
||||
// failure surface and custom-directory swap with in-component cwd derivation.
|
||||
// failure surface and path-modal confirm with in-component cwd derivation.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
@@ -165,7 +165,7 @@ describe('DetailsPanel branches', () => {
|
||||
|
||||
it('shows non-JSON args verbatim (streaming fragment path)', () => {
|
||||
const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, {
|
||||
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }],
|
||||
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }],
|
||||
})
|
||||
expect(view.getByText('{"cmd": tru')).toBeTruthy()
|
||||
})
|
||||
@@ -176,7 +176,7 @@ describe('DetailsPanel branches', () => {
|
||||
})
|
||||
|
||||
it('snapshot updates re-run the material selector through the shallow equality arm', () => {
|
||||
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot
|
||||
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot
|
||||
const subs = new Set<() => void>()
|
||||
const source = {
|
||||
getSnapshot: () => snap,
|
||||
@@ -238,10 +238,16 @@ describe('DetailsPanel branches', () => {
|
||||
})
|
||||
|
||||
describe('EmptyState branches', () => {
|
||||
const noopCreate = () => Promise.resolve()
|
||||
|
||||
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
|
||||
const view = render(
|
||||
<EmptyState useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])} startSession={startSession} />,
|
||||
<EmptyState
|
||||
useSessions={listHook([{ id: 'a', title: 'a', cwd: '/proj' }])}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'first task' } })
|
||||
@@ -253,7 +259,11 @@ describe('EmptyState branches', () => {
|
||||
it('non-Error rejection reasons stringify into the error strip', async () => {
|
||||
const startSession = vi.fn(() => Promise.reject('plain-string'))
|
||||
const view = render(
|
||||
<EmptyState useSessions={listHook([])} startSession={startSession} />,
|
||||
<EmptyState
|
||||
useSessions={listHook([])}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'go' } })
|
||||
@@ -261,7 +271,7 @@ describe('EmptyState branches', () => {
|
||||
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
|
||||
it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => {
|
||||
const startSession = vi.fn(() => Promise.resolve())
|
||||
const view = render(
|
||||
<EmptyState
|
||||
@@ -270,19 +280,39 @@ describe('EmptyState branches', () => {
|
||||
{ id: 'b', title: 'b' }, // no cwd: filtered from the option set
|
||||
])}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
const select = view.container.querySelector('select')!
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
.toEqual(['', '/proj', '::new-directory'])
|
||||
fireEvent.change(select, { target: { value: '/proj' } })
|
||||
expect((select as HTMLSelectElement).value).toBe('/proj')
|
||||
fireEvent.change(select, { target: { value: '::new-directory' } })
|
||||
const custom = view.container.querySelector('input')!
|
||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
||||
.toEqual(['proj', 'New Workspace'])
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
|
||||
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
|
||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' }))
|
||||
const custom = view.getByLabelText('Folder path')
|
||||
fireEvent.change(custom, { target: { value: '/typed/dir' } })
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open Folder' }))
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
fireEvent.change(textarea, { target: { value: 'task' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
|
||||
})
|
||||
|
||||
it('Create modal surfaces inject failures inline', async () => {
|
||||
const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked')))
|
||||
const view = render(
|
||||
<EmptyState
|
||||
useSessions={listHook([])}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(view.getByRole('menuitem', { name: 'Create new' }))
|
||||
fireEvent.click(view.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,13 +28,33 @@ const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
|
||||
globalThis.localStorage?.clear()
|
||||
})
|
||||
|
||||
/** Minimal conversation snapshot slice the skeleton reads. */
|
||||
interface FakeSnapshot {
|
||||
nodes: readonly { kind: string; callId?: string; call?: { name: string; argsRaw: string } | null; content?: readonly { type: string; text?: string }[]; isError?: boolean }[]
|
||||
runningCalls: readonly { callId: string; name: string; argsRaw: string }[]
|
||||
nodes: readonly {
|
||||
kind: string
|
||||
seq?: number
|
||||
time?: number
|
||||
callId?: string
|
||||
call?: { name: string; argsRaw: string } | null
|
||||
callTime?: number | null
|
||||
content?: readonly { type: string; text?: string }[]
|
||||
isError?: boolean
|
||||
callView?: null
|
||||
resultView?: null
|
||||
}[]
|
||||
runningCalls: readonly {
|
||||
callId: string
|
||||
name: string
|
||||
argsRaw: string
|
||||
turn?: number
|
||||
step?: number
|
||||
time?: number
|
||||
callView?: null
|
||||
}[]
|
||||
running: boolean
|
||||
removed: boolean
|
||||
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
|
||||
@@ -66,6 +86,8 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
|
||||
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
|
||||
|
||||
describe('EmptyState', () => {
|
||||
const noopCreate = () => Promise.resolve()
|
||||
|
||||
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
|
||||
const { useSessions } = fakeSessions([
|
||||
{ id: 'a', title: 'a', cwd: '/w/app' },
|
||||
@@ -74,13 +96,21 @@ describe('EmptyState', () => {
|
||||
])
|
||||
let reject!: (e: Error) => void
|
||||
const startSession = vi.fn(() => new Promise<void>((_res, rej) => { reject = rej }))
|
||||
render(<EmptyState useSessions={useSessions} startSession={startSession} />)
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={startSession}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
const select = screen.getByRole('combobox', { name: '项目目录' })
|
||||
expect([...(select as HTMLSelectElement).options].map(o => o.value))
|
||||
.toEqual(['', '/w/app', '/w/lib', '::new-directory'])
|
||||
fireEvent.change(select, { target: { value: '/w/app' } })
|
||||
const box = screen.getByPlaceholderText('Message to run task, plan and build')
|
||||
const trigger = screen.getByRole('button', { name: '项目目录' })
|
||||
fireEvent.click(trigger)
|
||||
const menu = screen.getByRole('menu')
|
||||
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
|
||||
.toEqual(['app', 'lib', 'New Workspace'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
|
||||
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
|
||||
fireEvent.change(box, { target: { value: '造一个轮子' } })
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
|
||||
@@ -91,13 +121,64 @@ describe('EmptyState', () => {
|
||||
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
|
||||
})
|
||||
|
||||
it('new-directory option swaps the select for a free-form input', () => {
|
||||
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
render(<EmptyState useSessions={useSessions} startSession={() => Promise.resolve()} />)
|
||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
|
||||
const custom = screen.getByPlaceholderText(/目录路径/)
|
||||
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
|
||||
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={noopCreate}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
|
||||
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
|
||||
const path = screen.getByLabelText('Folder path') as HTMLInputElement
|
||||
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
|
||||
})
|
||||
|
||||
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
|
||||
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
|
||||
expect(name.value).toBe('New WorkSpace')
|
||||
fireEvent.change(name, { target: { value: 'My Proj' } })
|
||||
fireEvent.keyDown(name, { key: 'Enter' })
|
||||
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
|
||||
})
|
||||
|
||||
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
|
||||
const { useSessions } = fakeSessions([])
|
||||
const createWorkspaceSession = vi.fn(() => Promise.resolve())
|
||||
render(
|
||||
<EmptyState
|
||||
useSessions={useSessions}
|
||||
startSession={() => Promise.resolve()}
|
||||
createWorkspaceSession={createWorkspaceSession}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
|
||||
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(createWorkspaceSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -234,10 +315,11 @@ describe('DetailsPanel', () => {
|
||||
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
|
||||
const { closeDetails } = benchDetails({
|
||||
nodes: [{
|
||||
kind: 'tool-result', callId: 'c1',
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
|
||||
callTime: 500,
|
||||
content: [{ type: 'text', text: 'file-a\nfile-b' }],
|
||||
isError: false,
|
||||
isError: false, callView: null, resultView: null,
|
||||
}],
|
||||
}, { turnSeq: 1, callId: 'c1' })
|
||||
expect(screen.getByText('bash')).toBeTruthy()
|
||||
@@ -248,10 +330,10 @@ describe('DetailsPanel', () => {
|
||||
})
|
||||
|
||||
it('shows the empty hint without a selection and the running state for open calls', () => {
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null)
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
|
||||
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
|
||||
cleanup()
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' })
|
||||
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
|
||||
expect(screen.getByText('运行中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-primitives
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-primitives",
|
||||
"description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Input, markdown family (zero cordis)",
|
||||
"description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -56,6 +56,20 @@
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* Dialog Cancel (figma 451:18655): bordered capsule on transparent fill. */
|
||||
.outline {
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.outline:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.outline:disabled {
|
||||
border-color: var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
background: var(--dsw-alias-button-tool-bar-fill);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import clsx from 'clsx'
|
||||
import css from './Button.module.css'
|
||||
|
||||
/** Visual variant, each backed by its --dsw-alias-button-* token family. */
|
||||
export type ButtonVariant = 'primary' | 'ghost' | 'toolbar'
|
||||
export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar'
|
||||
|
||||
/**
|
||||
* Render a button.
|
||||
|
||||
@@ -3,21 +3,32 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Dropdown card (figma MenuDropdown 122:10096): white card, r12, no border,
|
||||
* layered drop shadows via the shadow token, 4px inset padding. */
|
||||
/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface,
|
||||
* r12, inverted hairline border, shadow-lv3, 4px inset padding. */
|
||||
.list,
|
||||
.submenu {
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.list {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 130px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
}
|
||||
|
||||
/* Open above the anchor (empty-state workspace chip: figma 122:9481). */
|
||||
.sideTop {
|
||||
top: auto;
|
||||
bottom: calc(100% + 4px);
|
||||
}
|
||||
|
||||
.alignEnd {
|
||||
@@ -25,12 +36,18 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Menu cell (figma .Menu_cell 27:5169): r10, pad 10/8, 14/22 primary text,
|
||||
.itemWrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary,
|
||||
* gap 8 between leading icon / label / trailing check. */
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
@@ -51,9 +68,22 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.check {
|
||||
@@ -66,3 +96,34 @@
|
||||
.selected {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
|
||||
.separator {
|
||||
height: 1px;
|
||||
margin: 4px 2px;
|
||||
background: var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
/* Nested card to the right of the parent row (figma 419:16920).
|
||||
* Bottom-aligned with the parent menu card (grows upward): itemWrap sits in
|
||||
* .list's 4px pad, so bottom: -4px matches the list's outer bottom edge.
|
||||
* Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain
|
||||
* `100% + 6px` collapses to ~2px between outer card edges.
|
||||
* ::before bridges the full gap so the pointer can cross without mouseLeave. */
|
||||
.submenu {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
bottom: -4px;
|
||||
left: calc(100% + 10px);
|
||||
z-index: 101;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.submenu::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
@@ -1,46 +1,69 @@
|
||||
// Menu: minimal controlled dropdown (group-by pickers, project selectors).
|
||||
// Pure CSS positioning relative to the anchor wrapper — no portal, no popper.
|
||||
// The owner controls `open`; outside-click closing uses one document listener
|
||||
// active only while open.
|
||||
// active only while open. Submenus open on hover/focus inside the same root.
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16 } from './icons/index.tsx'
|
||||
import css from './Menu.module.css'
|
||||
|
||||
/** One selectable menu row. */
|
||||
/** Selectable row (optionally with a nested submenu). */
|
||||
export interface MenuItem {
|
||||
id: string
|
||||
label: ReactNode
|
||||
disabled?: boolean
|
||||
/** Leading icon (figma .Menu_cell gap 8). */
|
||||
icon?: ReactNode
|
||||
/** Nested card opened to the right on hover/focus. */
|
||||
submenu?: readonly MenuItem[]
|
||||
}
|
||||
|
||||
/** Hairline between item groups (not selectable). */
|
||||
export interface MenuSeparator {
|
||||
type: 'separator'
|
||||
id: string
|
||||
}
|
||||
|
||||
/** One primary-menu entry: a row or a separator. */
|
||||
export type MenuEntry = MenuItem | MenuSeparator
|
||||
|
||||
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
|
||||
return 'type' in entry && entry.type === 'separator'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an anchored dropdown menu.
|
||||
* @param props.open - whether the list is showing (owner-controlled).
|
||||
* @param props.anchor - the trigger element (rendered in place).
|
||||
* @param props.items - selectable rows.
|
||||
* @param props.items - selectable rows and optional separators.
|
||||
* @param props.selectedId - row shown as selected.
|
||||
* @param props.onSelect - row click callback (not called for disabled rows).
|
||||
* @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children).
|
||||
* @param props.onClose - invoked on outside click or Escape.
|
||||
* @param props.align - list alignment against the anchor (default 'start').
|
||||
* @param props.side - open below (`bottom`, default) or above (`top`) the anchor.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuItem[]
|
||||
items: readonly MenuEntry[]
|
||||
selectedId?: string
|
||||
onSelect: (id: string) => void
|
||||
onClose: () => void
|
||||
align?: 'start' | 'end'
|
||||
side?: 'bottom' | 'top'
|
||||
className?: string
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
setOpenSubmenuId(null)
|
||||
return
|
||||
}
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose()
|
||||
}
|
||||
@@ -59,21 +82,61 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
<span ref={rootRef} className={clsx(css.root, className)}>
|
||||
{anchor}
|
||||
{open && (
|
||||
<div className={clsx(css.list, align === 'end' && css.alignEnd)} role="menu">
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, item.id === selectedId && css.selected)}
|
||||
disabled={item.disabled}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className={css.itemLabel}>{item.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{item.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
))}
|
||||
<div className={clsx(css.list, side === 'top' && css.sideTop, align === 'end' && css.alignEnd)} role="menu">
|
||||
{items.map(entry => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
|
||||
const subOpen = hasSub && openSubmenuId === entry.id
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={css.itemWrap}
|
||||
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onMouseLeave={() => { setOpenSubmenuId(null) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, entry.id === selectedId && css.selected)}
|
||||
disabled={entry.disabled}
|
||||
aria-haspopup={hasSub ? 'menu' : undefined}
|
||||
aria-expanded={hasSub ? subOpen : undefined}
|
||||
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
|
||||
onClick={() => {
|
||||
if (hasSub) {
|
||||
setOpenSubmenuId(entry.id)
|
||||
return
|
||||
}
|
||||
onSelect(entry.id)
|
||||
}}
|
||||
>
|
||||
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
|
||||
<span className={css.itemLabel}>{entry.label}</span>
|
||||
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
|
||||
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
|
||||
</button>
|
||||
{subOpen && entry.submenu !== undefined && (
|
||||
<div className={css.submenu} role="menu">
|
||||
{entry.submenu.map(sub => (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={css.item}
|
||||
disabled={sub.disabled}
|
||||
onClick={() => { onSelect(sub.id) }}
|
||||
>
|
||||
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
|
||||
<span className={css.itemLabel}>{sub.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
|
||||
79
packages/client/ui-primitives/src/Modal.module.css
Normal file
79
packages/client/ui-primitives/src/Modal.module.css
Normal file
@@ -0,0 +1,79 @@
|
||||
/* Full-viewport layer (figma Mask + Dialog 451:18655): mask + centered card. */
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* User/spec mask: rgba(0,0,0,0.24) + blur(2px) via --dsw-alias-bg-mask-1 /
|
||||
--dsw-mask-blur (light); dark theme raises mask opacity. */
|
||||
.mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: var(--dsw-mask-blur);
|
||||
}
|
||||
|
||||
/* Dialog card: r24, shadow-lv3, layer-2 fill, inverted border, pb 24. */
|
||||
.dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: min(380px, 100%);
|
||||
padding: 0 0 24px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 24px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 22px 14px 12px 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
62
packages/client/ui-primitives/src/Modal.tsx
Normal file
62
packages/client/ui-primitives/src/Modal.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// Modal: controlled full-viewport dialog (create-workspace and similar).
|
||||
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
|
||||
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './Modal.module.css'
|
||||
|
||||
/**
|
||||
* Render a centered modal over a blurred page mask.
|
||||
* @param props.open - whether the dialog is showing.
|
||||
* @param props.onClose - Escape or mask click.
|
||||
* @param props.title - dialog heading.
|
||||
* @param props.description - optional supporting sentence under the title.
|
||||
* @param props.children - body (inputs, etc.).
|
||||
* @param props.footer - action row (Cancel / Create).
|
||||
* @returns null when closed; otherwise the overlay tree.
|
||||
*/
|
||||
export function Modal({ open, onClose, title, description, children, footer, className }: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
description?: string
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => { document.removeEventListener('keydown', onKeyDown) }
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className={css.root} role="presentation">
|
||||
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
||||
<div
|
||||
className={clsx(css.dialog, className)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className={css.content}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
{description !== undefined && description !== '' && (
|
||||
<p className={css.description}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children !== undefined && <div className={css.body}>{children}</div>}
|
||||
</div>
|
||||
{footer !== undefined && <div className={css.footer}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Input,
|
||||
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input,
|
||||
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
|
||||
* token vars only. Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
@@ -11,7 +11,8 @@ export type { ButtonVariant } from './Button.tsx'
|
||||
export { Pill } from './Pill.tsx'
|
||||
export { Input } from './Input.tsx'
|
||||
export { Menu } from './Menu.tsx'
|
||||
export type { MenuItem } from './Menu.tsx'
|
||||
export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx'
|
||||
export { Modal } from './Modal.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Button, ConnectionBanner, Input, Menu, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -21,6 +21,11 @@ describe('Button', () => {
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('outline variant renders a bordered cancel-style button', () => {
|
||||
render(<Button variant="outline">Cancel</Button>)
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pill', () => {
|
||||
@@ -91,11 +96,12 @@ describe('Menu', () => {
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selected item shows the trailing check; align=end and className apply', () => {
|
||||
it('selected item shows the trailing check; align=end, side=top, and className apply', () => {
|
||||
const { container } = render(
|
||||
<Menu
|
||||
open
|
||||
align="end"
|
||||
side="top"
|
||||
className="x"
|
||||
anchor={<span>trigger</span>}
|
||||
items={items}
|
||||
@@ -104,12 +110,89 @@ describe('Menu', () => {
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true)
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(menu.className).toMatch(/sideTop|alignEnd/)
|
||||
const selected = screen.getByRole('menuitem', { name: 'Alpha' })
|
||||
expect(selected.querySelector('svg')).not.toBeNull()
|
||||
const other = screen.getByRole('menuitem', { name: 'Beta' })
|
||||
expect(other.querySelector('svg')).toBeNull()
|
||||
fireEvent.keyDown(document, { key: 'a' })
|
||||
})
|
||||
|
||||
it('renders a leading icon and a separator between groups', () => {
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
anchor={<span>trigger</span>}
|
||||
items={[
|
||||
{ id: 'a', label: 'Alpha', icon: <svg data-testid="ic" /> },
|
||||
{ type: 'separator', id: 's1' },
|
||||
{ id: 'c', label: 'Create' },
|
||||
]}
|
||||
onSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
expect(screen.getByTestId('ic')).toBeDefined()
|
||||
expect(screen.getByRole('separator')).toBeDefined()
|
||||
})
|
||||
|
||||
it('opens a submenu on hover and selects a nested item', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
anchor={<span>trigger</span>}
|
||||
items={[
|
||||
{ id: 'plain', label: 'Plain' },
|
||||
{
|
||||
id: 'new',
|
||||
label: 'New Workspace',
|
||||
submenu: [
|
||||
{ id: 'ok', label: 'Create ok', icon: <svg data-testid="sub-ic" /> },
|
||||
],
|
||||
},
|
||||
]}
|
||||
onSelect={onSelect}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
const plain = screen.getByRole('menuitem', { name: 'Plain' })
|
||||
fireEvent.mouseEnter(plain.parentElement as HTMLElement)
|
||||
fireEvent.focus(plain)
|
||||
const parent = screen.getByRole('menuitem', { name: 'New Workspace' })
|
||||
const wrap = parent.parentElement as HTMLElement
|
||||
fireEvent.click(parent)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
fireEvent.focus(parent)
|
||||
fireEvent.mouseEnter(wrap)
|
||||
expect(screen.getByTestId('sub-ic')).toBeDefined()
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' }))
|
||||
expect(onSelect).toHaveBeenCalledWith('ok')
|
||||
fireEvent.mouseLeave(wrap)
|
||||
expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Modal', () => {
|
||||
it('is absent while closed; Escape and mask click call onClose', () => {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Modal open={false} onClose={onClose} title="Create new workspace">body</Modal>)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
rerender(
|
||||
<Modal open onClose={onClose} title="Create new workspace" description="Name it." footer={<button type="button">Create</button>}>
|
||||
<input aria-label="name" />
|
||||
</Modal>)
|
||||
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined()
|
||||
expect(screen.getByText('Name it.')).toBeDefined()
|
||||
fireEvent.keyDown(document, { key: 'a' })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// Mask is the presentation sibling behind the dialog.
|
||||
const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement
|
||||
fireEvent.click(mask)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ConnectionBanner', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-sidebar
|
||||
|
||||
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ export type SidebarRootInjected = {
|
||||
/** Open (switch to) a session. */
|
||||
onOpen: (id: SessionId) => void
|
||||
/**
|
||||
* Create a session and open it; cwd targets a project group (the
|
||||
* sidebar's three creation entries all land in the new session).
|
||||
* New-session affordance: no cwd clears selection onto the empty-state
|
||||
* launch; a cwd create-then-opens a session in that project group.
|
||||
*/
|
||||
onCreate: (cwd?: string) => void
|
||||
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
|
||||
|
||||
@@ -27,9 +27,15 @@ export function apply(ctx: ClientContext): void {
|
||||
// list snapshot); layout keeps only panel geometry.
|
||||
onOpen: (id) => { ctx.sessions.open(id) },
|
||||
onCreate: (cwd) => {
|
||||
// Create-then-open: the sidebar's three creation entries all land
|
||||
// in the new session (empty-state first-send stays with ui-conversation).
|
||||
void ctx.sessions.create(cwd === undefined ? {} : { cwd })
|
||||
// Top-level New Session / New Workspace: clear selection so AppFrame
|
||||
// shows conversation.empty (EmptyState + shared InputBar). Per-project
|
||||
// "+" still create-then-opens into that cwd until workspace seeding
|
||||
// reaches the empty-state picker.
|
||||
if (cwd === undefined) {
|
||||
ctx.sessions.clear()
|
||||
return
|
||||
}
|
||||
void ctx.sessions.create({ cwd })
|
||||
.then((id: SessionId) => { ctx.sessions.open(id) })
|
||||
},
|
||||
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
|
||||
|
||||
@@ -26,7 +26,12 @@ async function bench() {
|
||||
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: undefined,
|
||||
})
|
||||
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }
|
||||
const sessions = {
|
||||
list,
|
||||
create: vi.fn(async () => sid('minted')),
|
||||
open: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
ctx.provide('sessions', sessions)
|
||||
ctx.provide('layout', layout)
|
||||
@@ -91,14 +96,15 @@ describe('apply', () => {
|
||||
expect(sessions.open).toHaveBeenCalledWith('a')
|
||||
|
||||
injected.onCreate()
|
||||
expect(sessions.create).toHaveBeenCalledWith({})
|
||||
expect(sessions.clear).toHaveBeenCalledOnce()
|
||||
expect(sessions.create).not.toHaveBeenCalled()
|
||||
|
||||
injected.onCreate('/proj')
|
||||
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
// create-then-open lands after the create promise resolves.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(sessions.open).toHaveBeenCalledWith('minted')
|
||||
|
||||
injected.onCreate('/proj')
|
||||
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
})
|
||||
|
||||
it('teardown unregisters the slot entry', async () => {
|
||||
|
||||
@@ -169,6 +169,7 @@ body {
|
||||
--dsw-alias-border-l3: rgba(0, 0, 0, 0.12);
|
||||
--dsw-alias-border-l4: rgba(0, 0, 0, 0.16);
|
||||
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230);
|
||||
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000);
|
||||
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700);
|
||||
@@ -217,6 +218,7 @@ body {
|
||||
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-success-primary: var(--dsw-static-green-500);
|
||||
--dsw-alias-state-success-secondary: var(--dsw-static-green-400);
|
||||
--dsw-alias-state-success-tertiary: var(--dsw-static-green-100);
|
||||
--dsw-alias-state-warn-label: var(--dsw-static-amber-600);
|
||||
--dsw-alias-state-warn-primary: var(--dsw-static-amber-500);
|
||||
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
|
||||
@@ -257,11 +259,12 @@ body[data-ds-dark-theme] {
|
||||
--dsw-alias-border-l3: rgba(255, 255, 255, 0.16);
|
||||
--dsw-alias-border-l4: rgba(255, 255, 255, 0.2);
|
||||
--dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450);
|
||||
--dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50);
|
||||
--dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750);
|
||||
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-950);
|
||||
--dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850);
|
||||
--dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800);
|
||||
--dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600);
|
||||
--dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-client-ui-trajectory
|
||||
|
||||
Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Both views are placeholders by charter** — coarse span derivation with no visual acceptance bar; the real implementations, anchor deep-linking, and span-click selection handoff are the P-III project.
|
||||
- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* Trajectory step cell — 38px row: index · kind tag · text · optional message
|
||||
* metrics · elapsed time. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 38px;
|
||||
padding: 0 8px 0 20px;
|
||||
gap: 24px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: transparent;
|
||||
box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.index {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tagSlot {
|
||||
flex: none;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 22px;
|
||||
max-width: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tagUser {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
background: var(--dsw-alias-state-success-tertiary);
|
||||
}
|
||||
|
||||
.tagMessage {
|
||||
color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
background: var(--dsw-specific-bubble);
|
||||
}
|
||||
|
||||
.tagTool {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */
|
||||
.trailing {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 320px;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric,
|
||||
.time {
|
||||
flex: none;
|
||||
width: 71px;
|
||||
text-align: left;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
99
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
Normal file
99
packages/client/ui-trajectory/src/client/TrajectoryCell.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// TrajectoryCell: one step row in the trajectory list — index, kind tag,
|
||||
// ellipsis text, optional Message token metrics, and own-duration time.
|
||||
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import css from './TrajectoryCell.module.css'
|
||||
|
||||
/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */
|
||||
export type TrajectoryCellKind = 'user' | 'message' | 'tool'
|
||||
|
||||
/** Display label per kind (matches the design tags). */
|
||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||
user: 'User',
|
||||
message: 'Message',
|
||||
tool: 'Tool',
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<TrajectoryCellKind, string> = {
|
||||
user: css.tagUser!,
|
||||
message: css.tagMessage!,
|
||||
tool: css.tagTool!,
|
||||
}
|
||||
|
||||
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 1-based step index shown as `#N`. */
|
||||
index: number
|
||||
kind: TrajectoryCellKind
|
||||
/** Single-line summary; CSS ellipsis when it overflows. */
|
||||
text: string
|
||||
/**
|
||||
* Own duration in seconds. `null` means no duration to show (em dash) —
|
||||
* used for in-flight tools and tools missing callTime.
|
||||
*/
|
||||
timeSeconds: number | null
|
||||
/** Message-only: prompt token count. */
|
||||
input?: number
|
||||
/** Message-only: completion token count. */
|
||||
output?: number
|
||||
/** Message-only: reasoning token count (usage column, not a Think cell). */
|
||||
think?: number
|
||||
/** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Format own-duration for the trailing time column: `—` when unknown, `+Ns`
|
||||
* or `+N.1s` otherwise.
|
||||
* @param seconds - duration seconds, or null when absent.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatElapsedSeconds(seconds: number | null): string {
|
||||
if (seconds === null || !Number.isFinite(seconds)) return '—'
|
||||
const rounded = Math.round(seconds * 10) / 10
|
||||
if (Number.isInteger(rounded)) return `+${rounded}s`
|
||||
return `+${rounded.toFixed(1)}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one trajectory step cell.
|
||||
* @param props - index, kind, text, time, and optional Message metrics.
|
||||
* @returns the cell element.
|
||||
*/
|
||||
export function TrajectoryCell({
|
||||
index,
|
||||
kind,
|
||||
text,
|
||||
timeSeconds,
|
||||
input,
|
||||
output,
|
||||
think,
|
||||
selected = false,
|
||||
className,
|
||||
...rest
|
||||
}: TrajectoryCellProps) {
|
||||
const rootClass = [
|
||||
css.root,
|
||||
selected ? css.selected : undefined,
|
||||
className,
|
||||
].filter((c): c is string => c !== undefined).join(' ')
|
||||
const showMetrics = kind === 'message'
|
||||
return (
|
||||
<div className={rootClass} data-kind={kind} data-selected={selected || undefined} {...rest}>
|
||||
<span className={css.index}>#{index}</span>
|
||||
<span className={css.tagSlot}>
|
||||
<span className={`${css.tag} ${TAG_CLASS[kind]}`}>{KIND_LABEL[kind]}</span>
|
||||
</span>
|
||||
<span className={css.text}>{text}</span>
|
||||
<span className={css.trailing}>
|
||||
{showMetrics ? (
|
||||
<>
|
||||
<span className={css.metric}>{input ?? ''}</span>
|
||||
<span className={css.metric}>{output ?? ''}</span>
|
||||
<span className={css.metric}>{think ?? ''}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className={css.time}>{formatElapsedSeconds(timeSeconds)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Message / Step group title row inside a turn body. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 36px;
|
||||
padding: 0 20px;
|
||||
gap: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// TrajectoryGroupHeader: "Message" or "Step N" row with optional description.
|
||||
|
||||
import css from './TrajectoryGroupHeader.module.css'
|
||||
|
||||
export interface TrajectoryGroupHeaderProps {
|
||||
/** Group title (`Message`, `Step 1`, …). */
|
||||
title: string
|
||||
/** Secondary summary (`49s`, `2.2s skill`, …). */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a Message/Step group header inside a turn body.
|
||||
* @param props - title and optional description.
|
||||
* @returns the group header element.
|
||||
*/
|
||||
export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) {
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{description !== undefined && description !== ''
|
||||
? <span className={css.description}>{description}</span>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* One turn block: sticky header + padded body with 10px item gap. */
|
||||
|
||||
.root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px 22px;
|
||||
}
|
||||
26
packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx
Normal file
26
packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// TrajectoryTurn: sticky Turn header plus the padded Message/Step body.
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx'
|
||||
import css from './TrajectoryTurn.module.css'
|
||||
|
||||
export interface TrajectoryTurnProps {
|
||||
/** 1-based turn index for the sticky header. */
|
||||
turn: number
|
||||
/** Message / Step headers and TrajectoryCell rows. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one turn section (sticky header + body).
|
||||
* @param props - turn index and body children.
|
||||
* @returns the turn section element.
|
||||
*/
|
||||
export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) {
|
||||
return (
|
||||
<section className={css.root} data-turn={turn}>
|
||||
<TrajectoryTurnHeader turn={turn} />
|
||||
<div className={css.body}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* Sticky turn bar: full-bleed ghost-active fill across the panel; title +
|
||||
* metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */
|
||||
|
||||
.root {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: var(--dsw-alias-button-ghost-active-fill);
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.columns {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 320px;
|
||||
gap: 12px;
|
||||
/* Match cell padding-right: 8 so Time lines up with the trailing lane. */
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.column {
|
||||
flex: none;
|
||||
width: 71px;
|
||||
text-align: left;
|
||||
font: var(--dsw-font-xs-13);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels.
|
||||
|
||||
import css from './TrajectoryTurnHeader.module.css'
|
||||
|
||||
const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const
|
||||
|
||||
export interface TrajectoryTurnHeaderProps {
|
||||
/** 1-based turn index shown as `Turn N`. */
|
||||
turn: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sticky turn header row.
|
||||
* @param props.turn - turn index.
|
||||
* @returns the sticky header element.
|
||||
*/
|
||||
export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) {
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.inner}>
|
||||
<span className={css.title}>Turn {turn}</span>
|
||||
<div className={css.columns} aria-hidden="true">
|
||||
{COLUMN_LABELS.map((label) => (
|
||||
<span key={label} className={css.column}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,40 @@
|
||||
// TrajectoryView: P-I placeholder body for the trajectory tab — span stats
|
||||
// header over a per-turn span list with node-count weights (no timing data
|
||||
// exists yet; deviation ledger #3 defers real rendering to P-III).
|
||||
// TrajectoryView: sticky Turn sections with Message/Step groups and step cells.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { deriveSpans } from './spans.ts'
|
||||
import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx'
|
||||
import { TrajectoryCell } from './TrajectoryCell.tsx'
|
||||
import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from './TrajectoryTurn.tsx'
|
||||
import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
const nodes = useSession((s) => s.nodes)
|
||||
const spans = useMemo(() => deriveSpans(nodes), [nodes])
|
||||
if (spans.length === 0) return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
const partial = useSession((s) => s.partial)
|
||||
const runningCalls = useSession((s) => s.runningCalls)
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({ nodes, partial, runningCalls }),
|
||||
[nodes, partial, runningCalls],
|
||||
)
|
||||
if (turns.length === 0) {
|
||||
return <div className={css.root}><p className={css.empty}>暂无轨迹数据</p></div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<TrajectoryStatsHeader useSession={useSession} />
|
||||
<div className={css.root}>
|
||||
{spans.map((span) => (
|
||||
<div key={span.turn} className={css.row}>
|
||||
<span className={css.turnTag}>turn {span.turn}</span>
|
||||
<span className={css.meta}>
|
||||
{span.steps} steps · {span.calls} calls · {span.nodes} nodes
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
<div className={css.root}>
|
||||
{turns.map((turn) => (
|
||||
<TrajectoryTurn key={turn.turn} turn={turn.turn}>
|
||||
{turn.groups.flatMap((group) => [
|
||||
<TrajectoryGroupHeader
|
||||
key={`${group.title}-h`}
|
||||
title={group.title}
|
||||
{...(group.description !== undefined ? { description: group.description } : {})}
|
||||
/>,
|
||||
...group.cells.map((cell) => (
|
||||
<TrajectoryCell key={cell.index} {...cell} />
|
||||
)),
|
||||
])}
|
||||
</TrajectoryTurn>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation']
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
* registrations ride the slot service's effect wrapper (plugin unload
|
||||
* removes both tabs); the span stats header renders inside each view body
|
||||
* (the chrome attachment mechanism retired with the view ring).
|
||||
* removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps
|
||||
* the span stats header inside its body (chrome attachment retired).
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
|
||||
418
packages/client/ui-trajectory/src/client/layout.ts
Normal file
418
packages/client/ui-trajectory/src/client/layout.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Trajectory list fold: expand assistant blocks, attach usage to Message,
|
||||
* own-duration times, in-flight partial/runningCalls, and group descriptions.
|
||||
*/
|
||||
import type {
|
||||
AssistantMessageNode,
|
||||
ConversationSnapshot,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { TrajectoryCellProps } from './TrajectoryCell.tsx'
|
||||
|
||||
/** One Message or Step group inside a turn. */
|
||||
export interface TrajectoryGroupModel {
|
||||
title: string
|
||||
description?: string
|
||||
cells: readonly TrajectoryCellProps[]
|
||||
}
|
||||
|
||||
/** One sticky-turn section. */
|
||||
export interface TrajectoryTurnModel {
|
||||
turn: number
|
||||
groups: readonly TrajectoryGroupModel[]
|
||||
}
|
||||
|
||||
/** Snapshot slice the trajectory view folds. */
|
||||
export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
/** Cell plus absolute ms for group wall-span descriptions. */
|
||||
interface LaidCell {
|
||||
cell: TrajectoryCellProps
|
||||
absTime: number | null
|
||||
toolName?: string
|
||||
callId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a snapshot into turn → Message/Step groups with expanded cells.
|
||||
* @param input - nodes plus in-flight partial/runningCalls.
|
||||
* @returns turns ordered by first appearance.
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const { nodes, partial, runningCalls } = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const turns = new Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
|
||||
let index = 0
|
||||
let prevAbsTime: number | null = null
|
||||
let lastAssistantTurn: number | null = null
|
||||
|
||||
const bucket = (turn: number) => {
|
||||
let entry = turns.get(turn)
|
||||
if (entry === undefined) {
|
||||
entry = { message: [], steps: new Map() }
|
||||
turns.set(turn, entry)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
const pushMessage = (turn: number, laid: LaidCell) => {
|
||||
bucket(turn).message.push(laid)
|
||||
}
|
||||
const pushStep = (turn: number, step: number, laid: LaidCell) => {
|
||||
const steps = bucket(turn).steps
|
||||
const list = steps.get(step) ?? []
|
||||
list.push(laid)
|
||||
steps.set(step, list)
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (node === undefined) continue
|
||||
if (node.kind === 'user' || node.kind === 'steering') {
|
||||
// user/message has no turn on the wire; enclose it in the next assistant
|
||||
// (or partial) turn, else open the turn after the last assistant.
|
||||
const turn = node.kind === 'steering'
|
||||
? node.turn
|
||||
: enclosingUserTurn(nodes, i, partial, lastAssistantTurn)
|
||||
pushMessage(turn, {
|
||||
absTime: finiteTime(node.time),
|
||||
cell: {
|
||||
index: ++index, kind: 'user', text: summarizeContent(node.content),
|
||||
timeSeconds: 0,
|
||||
},
|
||||
})
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'assistant') {
|
||||
const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall)
|
||||
for (const laid of laidList) {
|
||||
if (node.step > 0) pushStep(node.turn, node.step, laid)
|
||||
else pushMessage(node.turn, laid)
|
||||
}
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
lastAssistantTurn = node.turn
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'context') {
|
||||
// No trajectory cell, but the surface still advances the duration cursor.
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
continue
|
||||
}
|
||||
if (node.kind === 'tool-result') {
|
||||
if (!callEmittedInAssistant(nodes, node.callId)) {
|
||||
const toolName = node.call?.name
|
||||
pushStep(0, 1, {
|
||||
absTime: finiteTime(node.callTime ?? node.time),
|
||||
...(toolName !== undefined ? { toolName } : {}),
|
||||
callId: node.callId,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
text: node.call !== null
|
||||
? summarizeCall(node.call.name, node.call.argsRaw)
|
||||
: summarizeResult(node),
|
||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||
},
|
||||
})
|
||||
}
|
||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||
}
|
||||
}
|
||||
|
||||
if (partial !== null) {
|
||||
const fake: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0,
|
||||
turn: partial.turn, step: partial.step, blocks: partial.blocks,
|
||||
}
|
||||
const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true })
|
||||
for (const laid of laidList) {
|
||||
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
|
||||
else pushMessage(partial.turn, laid)
|
||||
}
|
||||
const last = laidList[laidList.length - 1]
|
||||
if (last !== undefined) index = last.cell.index
|
||||
}
|
||||
|
||||
const seenCalls = collectCallIds(turns)
|
||||
for (const call of runningCalls) {
|
||||
if (seenCalls.has(call.callId)) continue
|
||||
pushStep(call.turn, call.step > 0 ? call.step : 1, {
|
||||
absTime: null,
|
||||
toolName: call.name,
|
||||
callId: call.callId,
|
||||
cell: {
|
||||
index: ++index,
|
||||
kind: 'tool',
|
||||
text: summarizeCall(call.name, call.argsRaw),
|
||||
timeSeconds: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||||
const prologue = turns.get(0)
|
||||
if (prologue !== undefined) {
|
||||
turns.delete(0)
|
||||
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
|
||||
message: [],
|
||||
steps: new Map(),
|
||||
})
|
||||
const first = turns.get(1) ?? emptyTurn()
|
||||
first.message = [...prologue.message, ...first.message]
|
||||
for (const [step, cells] of prologue.steps) {
|
||||
const existing = first.steps.get(step) ?? []
|
||||
first.steps.set(step, [...cells, ...existing])
|
||||
}
|
||||
turns.set(1, first)
|
||||
}
|
||||
|
||||
return [...turns.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([turn, entry]) => toTurnModel(turn, entry))
|
||||
}
|
||||
|
||||
function toTurnModel(
|
||||
turn: number,
|
||||
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
|
||||
): TrajectoryTurnModel {
|
||||
const groups: TrajectoryGroupModel[] = []
|
||||
if (entry.message.length > 0) {
|
||||
const description = groupDescription(entry.message)
|
||||
groups.push({
|
||||
title: 'Message',
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: entry.message.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) {
|
||||
const laid = entry.steps.get(step) ?? []
|
||||
const description = groupDescription(laid)
|
||||
groups.push({
|
||||
title: `Step ${step}`,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
cells: laid.map(l => l.cell),
|
||||
})
|
||||
}
|
||||
return { turn, groups }
|
||||
}
|
||||
|
||||
/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */
|
||||
function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||
const parts: string[] = []
|
||||
// Tool rows contribute start (absTime) and end (start + own duration) so a
|
||||
// single Tool cell still spans call→result for the group wall clock.
|
||||
const times: number[] = []
|
||||
for (const l of laid) {
|
||||
if (l.absTime === null || !Number.isFinite(l.absTime)) continue
|
||||
times.push(l.absTime)
|
||||
if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) {
|
||||
times.push(l.absTime + l.cell.timeSeconds * 1000)
|
||||
}
|
||||
}
|
||||
if (times.length >= 2) {
|
||||
const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000)
|
||||
if (span !== undefined) parts.push(span)
|
||||
} else if (times.length === 1) {
|
||||
const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds
|
||||
const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined
|
||||
if (span !== undefined) parts.push(span)
|
||||
}
|
||||
const tools = new Map<string, number>()
|
||||
for (const l of laid) {
|
||||
if (l.toolName === undefined || l.cell.kind !== 'tool') continue
|
||||
tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1)
|
||||
}
|
||||
for (const [name, count] of tools) {
|
||||
parts.push(count > 1 ? `${name}×${count}` : name)
|
||||
}
|
||||
return parts.length === 0 ? undefined : parts.join(' ')
|
||||
}
|
||||
|
||||
function formatGroupDuration(seconds: number): string | undefined {
|
||||
if (!Number.isFinite(seconds)) return undefined
|
||||
const rounded = Math.round(seconds * 10) / 10
|
||||
if (Number.isInteger(rounded)) return `${rounded}s`
|
||||
return `${rounded.toFixed(1)}s`
|
||||
}
|
||||
|
||||
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
|
||||
function durationSeconds(later: number, earlier: number | null): number | null {
|
||||
if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null
|
||||
return Math.max(0, (later - earlier) / 1000)
|
||||
}
|
||||
|
||||
/** Epoch-ms usable as an absolute time, else null. */
|
||||
function finiteTime(time: number): number | null {
|
||||
return Number.isFinite(time) ? time : null
|
||||
}
|
||||
|
||||
function expandAssistant(
|
||||
node: AssistantMessageNode,
|
||||
startIndex: number,
|
||||
prevAbsTime: number | null,
|
||||
results: Map<string, ToolResultNode>,
|
||||
opts?: { streaming?: boolean },
|
||||
): LaidCell[] {
|
||||
const out: LaidCell[] = []
|
||||
let index = startIndex - 1
|
||||
const usage = node.usage as UsageLike | undefined
|
||||
const streaming = opts?.streaming === true
|
||||
const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime)
|
||||
const nodeAbs = streaming ? null : finiteTime(node.time)
|
||||
let usageAttached = false
|
||||
|
||||
for (const block of node.blocks) {
|
||||
// Reasoning blocks are skipped: no block-level clock, so no Think cell.
|
||||
if (block.kind === 'reasoning') continue
|
||||
if (block.kind === 'text') {
|
||||
if (block.text === '' && streaming) continue
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: summarizeText(block.text),
|
||||
timeSeconds: messageDuration,
|
||||
}
|
||||
if (!usageAttached) {
|
||||
attachUsage(cell, usage)
|
||||
usageAttached = usage !== undefined
|
||||
}
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
continue
|
||||
}
|
||||
if (block.kind === 'tool-call') {
|
||||
const result = results.get(block.callId)
|
||||
const toolDuration = streaming || result === undefined
|
||||
? null
|
||||
: durationSeconds(result.time, result.callTime)
|
||||
const callAbs = streaming
|
||||
? null
|
||||
: (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime)
|
||||
? result.callTime
|
||||
: nodeAbs)
|
||||
out.push({
|
||||
absTime: callAbs,
|
||||
toolName: block.name,
|
||||
callId: block.callId,
|
||||
cell: {
|
||||
index: ++index, kind: 'tool',
|
||||
text: summarizeCall(block.name, block.argsRaw),
|
||||
timeSeconds: toolDuration,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (out.length === 0 && !streaming) {
|
||||
// Reasoning-only / empty success still owns provider usage on the Message row.
|
||||
const cell: TrajectoryCellProps = {
|
||||
index: ++index, kind: 'message', text: '', timeSeconds: messageDuration,
|
||||
}
|
||||
attachUsage(cell, usage)
|
||||
out.push({ absTime: nodeAbs, cell })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn that encloses a user/message: next assistant/steering turn, else the
|
||||
* in-flight partial, else the turn after the last finalized assistant (or 1).
|
||||
*/
|
||||
function enclosingUserTurn(
|
||||
nodes: ConversationSnapshot['nodes'],
|
||||
userIndex: number,
|
||||
partial: ConversationSnapshot['partial'],
|
||||
lastAssistantTurn: number | null,
|
||||
): number {
|
||||
for (let i = userIndex + 1; i < nodes.length; i++) {
|
||||
const n = nodes[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
if (n === undefined) continue
|
||||
if (n.kind === 'assistant' || n.kind === 'steering') return n.turn
|
||||
}
|
||||
if (partial !== null) return partial.turn
|
||||
if (lastAssistantTurn !== null) return lastAssistantTurn + 1
|
||||
return 1
|
||||
}
|
||||
|
||||
/** Copy provider usage onto a Message cell when present. */
|
||||
function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void {
|
||||
if (usage === undefined) return
|
||||
if (usage.inputTokens !== undefined) cell.input = usage.inputTokens
|
||||
if (usage.outputTokens !== undefined) cell.output = usage.outputTokens
|
||||
if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens
|
||||
}
|
||||
|
||||
function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolResultNode> {
|
||||
const map = new Map<string, ToolResultNode>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-result') map.set(node.callId, node)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant') continue
|
||||
if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function collectCallIds(
|
||||
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
|
||||
): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const entry of turns.values()) {
|
||||
for (const laid of entry.message) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
for (const list of entry.steps.values()) {
|
||||
for (const laid of list) {
|
||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function summarizeCall(name: string, argsRaw: string): string {
|
||||
const args = argsRaw.replace(/\s+/g, ' ').trim()
|
||||
if (args === '') return name
|
||||
const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args
|
||||
return `${name} · ${clipped}`
|
||||
}
|
||||
|
||||
function summarizeResult(node: ToolResultNode): string {
|
||||
if (node.isError) {
|
||||
return node.error?.code ?? 'error'
|
||||
}
|
||||
for (const block of node.content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
|
||||
return summarizeText(block.text)
|
||||
}
|
||||
}
|
||||
return node.call?.name ?? node.callId
|
||||
}
|
||||
|
||||
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
|
||||
for (const block of content) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function summarizeText(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
@@ -1,25 +1,34 @@
|
||||
/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge;
|
||||
* cell content width is capped on the turn body (max 880). */
|
||||
.root {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* Waterfall placeholder rows (shared module). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
.turnTag {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.bar {
|
||||
@@ -29,9 +38,10 @@
|
||||
}
|
||||
|
||||
.barCalls {
|
||||
background: var(--dsw-alias-brand-primary);
|
||||
background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
87
packages/client/ui-trajectory/tests/cell.spec.tsx
Normal file
87
packages/client/ui-trajectory/tests/cell.spec.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message
|
||||
* metric columns, own-duration formatting, and selected ring.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import {
|
||||
formatElapsedSeconds,
|
||||
TrajectoryCell,
|
||||
type TrajectoryCellKind,
|
||||
} from '../src/client/TrajectoryCell.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('formatElapsedSeconds', () => {
|
||||
it('formats known durations and uses an em dash when absent', () => {
|
||||
expect(formatElapsedSeconds(null)).toBe('—')
|
||||
expect(formatElapsedSeconds(235)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.0)).toBe('+235s')
|
||||
expect(formatElapsedSeconds(235.2)).toBe('+235.2s')
|
||||
expect(formatElapsedSeconds(235.25)).toBe('+235.3s')
|
||||
expect(formatElapsedSeconds(0)).toBe('+0s')
|
||||
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryCell', () => {
|
||||
it('renders index, kind tag, text, and time for a Tool row', () => {
|
||||
render(
|
||||
<TrajectoryCell
|
||||
index={6}
|
||||
kind="tool"
|
||||
text="bash · Read src/index.ts"
|
||||
timeSeconds={5}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('#6')).toBeTruthy()
|
||||
expect(screen.getByText('Tool')).toBeTruthy()
|
||||
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
|
||||
expect(screen.getByText('+5s')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Message rows expose Input / Output / Think metric columns before time', () => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell
|
||||
index={3}
|
||||
kind="message"
|
||||
text="Let me now read the actual source files to understa..."
|
||||
timeSeconds={235.2}
|
||||
input={136}
|
||||
output={381}
|
||||
think={155}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(screen.getByText('136')).toBeTruthy()
|
||||
expect(screen.getByText('381')).toBeTruthy()
|
||||
expect(screen.getByText('155')).toBeTruthy()
|
||||
expect(screen.getByText('+235.2s')).toBeTruthy()
|
||||
const texts = [...container.querySelectorAll('span')].map((el) => el.textContent)
|
||||
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
|
||||
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
|
||||
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s'))
|
||||
})
|
||||
|
||||
it('selected marks the row for the brand-primary inset ring', () => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell index={15} kind="message" text="pictur..." timeSeconds={123.6} selected />,
|
||||
)
|
||||
expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['user', 'User'],
|
||||
['tool', 'Tool'],
|
||||
] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => {
|
||||
const { container } = render(
|
||||
<TrajectoryCell index={1} kind={kind} text="summary" timeSeconds={kind === 'user' ? 0 : null} input={1} output={2} think={3} />,
|
||||
)
|
||||
expect(screen.getByText(label)).toBeTruthy()
|
||||
expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind)
|
||||
expect(screen.queryByText('1')).toBeNull()
|
||||
expect(screen.queryByText('2')).toBeNull()
|
||||
expect(screen.queryByText('3')).toBeNull()
|
||||
})
|
||||
})
|
||||
206
packages/client/ui-trajectory/tests/layout.spec.tsx
Normal file
206
packages/client/ui-trajectory/tests/layout.spec.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Trajectory turn chrome and layout fold: expand blocks, usage on Message,
|
||||
* tool own-duration, group wall-span descriptions, in-flight rows.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
|
||||
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
|
||||
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
|
||||
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('TrajectoryTurnHeader', () => {
|
||||
it('renders Turn N and the four metric column labels', () => {
|
||||
render(<TrajectoryTurnHeader turn={1} />)
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Input')).toBeTruthy()
|
||||
expect(screen.getByText('Output')).toBeTruthy()
|
||||
expect(screen.getByText('Think')).toBeTruthy()
|
||||
expect(screen.getByText('Time')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryGroupHeader', () => {
|
||||
it('renders title and optional description', () => {
|
||||
render(<TrajectoryGroupHeader title="Step 1" description="2.2s skill" />)
|
||||
expect(screen.getByText('Step 1')).toBeTruthy()
|
||||
expect(screen.getByText('2.2s skill')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the description node when absent', () => {
|
||||
const { container } = render(<TrajectoryGroupHeader title="Message" />)
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(container.querySelectorAll('span')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrajectoryTurn', () => {
|
||||
it('wraps a sticky header and body children', () => {
|
||||
render(
|
||||
<TrajectoryTurn turn={3}>
|
||||
<TrajectoryGroupHeader title="Message" description="49s" />
|
||||
</TrajectoryTurn>,
|
||||
)
|
||||
expect(screen.getByText('Turn 3')).toBeTruthy()
|
||||
expect(screen.getByText('Message')).toBeTruthy()
|
||||
expect(screen.getByText('49s')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveTrajectoryLayout', () => {
|
||||
it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'reasoning', text: 'thinking…' },
|
||||
{ kind: 'text', text: 'I will run bash' },
|
||||
{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' },
|
||||
],
|
||||
usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 },
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200,
|
||||
content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns).toHaveLength(1)
|
||||
expect(turns[0]?.turn).toBe(1)
|
||||
const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind))
|
||||
expect(kinds).toEqual(['user', 'message', 'tool'])
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
input: 10, output: 20, think: 5, timeSeconds: 5,
|
||||
})
|
||||
const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool')
|
||||
expect(tool?.text).toBe('bash · {"command":"ls"}')
|
||||
expect(tool?.timeSeconds).toBe(1.3)
|
||||
})
|
||||
|
||||
it('adds runningCalls not already present and leaves their time blank', () => {
|
||||
const turns = deriveTrajectoryLayout({
|
||||
nodes: [] as unknown as ConversationSnapshot['nodes'],
|
||||
partial: null,
|
||||
runningCalls: [{
|
||||
callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}',
|
||||
turn: 1, step: 2, time: 9_000, callView: null,
|
||||
}],
|
||||
})
|
||||
expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2'])
|
||||
expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({
|
||||
kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits duration when node times are missing instead of rendering NaN', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'reasoning', text: '…' },
|
||||
{ kind: 'text', text: 'ok' },
|
||||
],
|
||||
usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? []
|
||||
expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull()
|
||||
expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined()
|
||||
})
|
||||
|
||||
it('builds a wall-span step description with a tool histogram', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1,
|
||||
blocks: [
|
||||
{ kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' },
|
||||
{ kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 2, time: 2_500, callId: 'a',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 4_000, callId: 'b',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2')
|
||||
})
|
||||
|
||||
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'ok1' }],
|
||||
},
|
||||
{ kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'ok2' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
expect(turns.map((t) => t.turn)).toEqual([1, 2])
|
||||
expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1'])
|
||||
expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2'])
|
||||
})
|
||||
|
||||
it('keeps usage on the fallback Message row when assistant has no text block', () => {
|
||||
const nodes = [
|
||||
{
|
||||
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'reasoning', text: '…' }],
|
||||
usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 },
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message')
|
||||
expect(message).toMatchObject({
|
||||
text: '', input: 11, output: 22, think: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('advances the duration cursor over context nodes', () => {
|
||||
const nodes = [
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||
{
|
||||
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
|
||||
blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }],
|
||||
},
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{
|
||||
kind: 'context', seq: 4, time: 9_000,
|
||||
content: [{ type: 'text', text: 'extra' }], source: null,
|
||||
},
|
||||
{
|
||||
kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0,
|
||||
blocks: [{ kind: 'text', text: 'done' }],
|
||||
},
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] })
|
||||
const message = turns[0]?.groups
|
||||
.flatMap((g) => g.cells)
|
||||
.find((c) => c.kind === 'message' && c.text === 'done')
|
||||
// From context at 9s, not from the earlier user/tool surfaces.
|
||||
expect(message?.timeSeconds).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -3,9 +3,9 @@
|
||||
* View registration acceptance on the real framework stack: the plugin fiber
|
||||
* registers trajectory/waterfall into a real SlotsService view ring, tabs
|
||||
* switch inside ConversationRoot (renderSlot share driven by the same tab
|
||||
* projection apply uses) without collapsing chat, the span stats header
|
||||
* renders inside both view bodies, and fiber disposal removes both tabs.
|
||||
* Span derivation edge cases ride along.
|
||||
* projection apply uses) without collapsing chat, trajectory renders the
|
||||
* turn-list chrome (no span stats bar), waterfall keeps in-body stats, and
|
||||
* fiber disposal removes both tabs. Span derivation edge cases ride along.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -36,19 +36,26 @@ afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear so one case's active
|
||||
// view cannot rehydrate into the next.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// Node 22+ exposes an experimental localStorage global that is undefined
|
||||
// without --localstorage-file; only clear when a real Storage is present.
|
||||
if (typeof localStorage !== 'undefined') localStorage.clear()
|
||||
})
|
||||
|
||||
/** Node fixture: user prologue, two turns, one tool result inside turn 1. */
|
||||
const NODES = [
|
||||
{ kind: 'user', seq: 1, content: [], source: null },
|
||||
{ kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] },
|
||||
{ kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null },
|
||||
{ kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] },
|
||||
{ kind: 'user', seq: 1, time: 1_000, content: [], source: null },
|
||||
{ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] },
|
||||
{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null,
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
},
|
||||
{ kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] },
|
||||
] as unknown as ConversationSnapshot['nodes']
|
||||
|
||||
function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes })
|
||||
const store = createSnapshotStore({
|
||||
nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
@@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
|
||||
/** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'],
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
@@ -158,17 +166,20 @@ describe('plugin registration', () => {
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => {
|
||||
it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => {
|
||||
const b = await bench()
|
||||
mount(b.slots)
|
||||
expect(screen.getByTestId('chat-body')).toBeTruthy()
|
||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
// In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call.
|
||||
expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy()
|
||||
expect(screen.getByText('turn 0')).toBeTruthy()
|
||||
expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy()
|
||||
// Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Turn 2')).toBeTruthy()
|
||||
expect(screen.getAllByText('Message').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('Input').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByTestId('chat-body')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { mkdir, stat } from 'node:fs/promises'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
@@ -407,8 +407,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
const sessionId = `session-${randomUUID()}` as SessionId
|
||||
// A session's cwd is its project path. When the creator does not choose
|
||||
// one, the default project is the host-level default (the host process
|
||||
// working directory unless boot overrides it).
|
||||
// working directory unless boot overrides it). Ensure the directory
|
||||
// exists so Create-workspace and typed paths land on a real folder.
|
||||
const cwd = request.payload.cwd ?? defaults.cwd
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
} catch (error: unknown) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `failed to ensure project directory "${cwd}": ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
|
||||
return ok(request, { sessionId: handle.agent.id })
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -231,6 +231,29 @@ describe('sessions.create / list', () => {
|
||||
expect(first?.running).toBe(false)
|
||||
expect(first?.parentSessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ensures a missing project directory before minting the session', async () => {
|
||||
const { api } = await boot()
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-'))
|
||||
const cwd = join(root, 'nested', 'workspace')
|
||||
expect(existsSync(cwd)).toBe(false)
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({ cwd })))
|
||||
expect(existsSync(cwd)).toBe(true)
|
||||
const { items } = expectOk(await api.sessions.list(request({})))
|
||||
expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd)
|
||||
})
|
||||
|
||||
it('fails loud when the project directory cannot be created', async () => {
|
||||
const { api } = await boot()
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-'))
|
||||
const blocker = join(root, 'file-not-dir')
|
||||
writeFileSync(blocker, 'x')
|
||||
const response = await api.sessions.create(request({ cwd: join(blocker, 'child') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (response.result.ok) throw new Error('expected mkdir failure')
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toMatch(/failed to ensure project directory/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.prompt / cancel', () => {
|
||||
|
||||
Reference in New Issue
Block a user