Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress
# Conflicts: # docs/core-data-structures/session.i18n.yaml # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
This commit is contained in:
@@ -779,6 +779,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
pickDirectory: request => ok(request, { path: null }),
|
||||
openPath: request => ok(request, { opened: true as const }),
|
||||
},
|
||||
workspace: {
|
||||
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
|
||||
@@ -1030,6 +1031,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.cancel': return this.api.sessions.cancel(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
|
||||
case 'workspace.list': return this.api.workspace.list(request)
|
||||
case 'workspace.create': return this.api.workspace.create(request)
|
||||
case 'workspace.rename': return this.api.workspace.rename(request)
|
||||
|
||||
@@ -26,7 +26,8 @@ export function apply(ctx: Context): void {
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
if (pathname === `${API_PATH}/host.pickDirectory`
|
||||
if ((pathname === `${API_PATH}/host.pickDirectory`
|
||||
|| pathname === `${API_PATH}/host.openPath`)
|
||||
&& !isTrustedNativeDialogRequest(req)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Trust check for browser requests that can open an operating-system dialog. */
|
||||
/** Trust check for browser requests that can invoke privileged native host actions. */
|
||||
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ export class FakeApiClient implements IApiClient {
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -88,6 +90,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
readonly workspace: IApiClient['workspace'] = {
|
||||
|
||||
@@ -28,22 +28,24 @@ describe('connection node half', () => {
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url: '/api/host.pickDirectory',
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
|
||||
let status: number | undefined
|
||||
let body: unknown
|
||||
const deniedRequest = {
|
||||
url,
|
||||
headers: {
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
},
|
||||
socket: { remoteAddress: '192.168.1.8' },
|
||||
} as unknown as IncomingMessage
|
||||
const deniedResponse = {
|
||||
writeHead(value: number) { status = value; return this },
|
||||
end(value?: unknown) { body = value; return this },
|
||||
} as unknown as ServerResponse
|
||||
await routes[0]!.handler(deniedRequest, deniedResponse)
|
||||
expect(status).toBe(403)
|
||||
expect(body).toBe('forbidden')
|
||||
}
|
||||
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -182,6 +182,17 @@ export class WorkspacesService {
|
||||
return response.result.value.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the Host operating system's default application.
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
async openPath(path: string): Promise<void> {
|
||||
const response = await this.api.host.openPath({ path })
|
||||
if (!response.result.ok) {
|
||||
throw new Error(`path open failed: ${response.result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -84,6 +84,8 @@ export class FakeApiClient implements IApiClient {
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
|
||||
() => Promise.resolve(ok({ opened: true as const }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
@@ -106,6 +108,7 @@ export class FakeApiClient implements IApiClient {
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
|
||||
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
|
||||
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
|
||||
}
|
||||
|
||||
onWorkspaceList: (payload: unknown) => Promise<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
|
||||
@@ -236,6 +236,17 @@ describe('WorkspacesService', () => {
|
||||
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
|
||||
})
|
||||
|
||||
it('opens a filesystem path through the host without local state', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
|
||||
expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
|
||||
api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
|
||||
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 658a6d5a3bc3f94a90639ddc730b2195504675cd
|
||||
README.zh.md: f5510490e5130fb91af72558798aad2a4269b8c0
|
||||
README.md: 6cb4952eff2a98f8294c68ab96693a396ae32c5f
|
||||
README.zh.md: 563fbae996bf19822f8b600489a6168e9c736600
|
||||
|
||||
@@ -8,9 +8,9 @@ The resident conversation shell survives no-session and session transitions. Wit
|
||||
|
||||
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
|
||||
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and `Write · <path>` or `Edit · <path>` summary while retaining the shared row-to-details interaction. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged), and the details panel resolves a selected sub-call id to its full logged args and complete output. Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` plus a `+<n>` count of the other active ones in its own non-shrinking span, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). Several items may be `in_progress` at once (the tool permits parallel work), so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the durable plan strip: it selects `todos` off the session snapshot and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the persistent list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
视图环本身就是 slot:会话注册声明 `'conversation.view'` 列表 slot(Session scope),并将其列在 `children` 表中;ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页从环账本的注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的环配置项;其他插件(ui-trajectory)通过普通的 `ctx.slots.register` 贡献标签页。先前包内的视图注册表(`registerView`/`ViewEntry`/`ConversationViewMap` 及 chrome 附加表)已退役,逐视图 chrome 则被拆入视图组件自身。
|
||||
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和 `Write · <path>` 或 `Edit · <path>` 摘要,同时保留共享的行到详情交互。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行),details 面板则会根据选中的子调用 id 解析出其完整记录的参数与完整输出。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
|
||||
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,并把「其余活跃项的数量」`+<n>` 放在自己的不收缩 span 里;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。可以有多个条目同时处于 `in_progress`(工具允许并行工作),因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是常驻的计划条:它从会话快照中选取 `todos` 并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏,折叠时收成标题加 `"<已完成>/<总数> tasks · <n> in progress"` 的表头(状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;常驻列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
|
||||
} from './contract/slots.ts'
|
||||
import { resolveToolPath } from './contract/tool-call-model.ts'
|
||||
import { createChatStore } from './stores.ts'
|
||||
import { ConversationService } from './service.ts'
|
||||
import { InputHub } from './input/hub.ts'
|
||||
@@ -167,6 +168,13 @@ export function apply(ctx: Context): void {
|
||||
actions.select(target)
|
||||
layout.openDetails()
|
||||
},
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
|
||||
// Host/OS open failures stay silent in the chat row; the native
|
||||
// app surfaces its own error dialog when the path is unusable.
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,18 +9,6 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.pulse {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
animation: pulse 1s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
|
||||
.stopped {
|
||||
align-self: flex-start;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// reasoning as the figma Think summary row (expand = indented gray text),
|
||||
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial
|
||||
// (pulse marker).
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css'
|
||||
export interface AssistantMarkdownProps {
|
||||
blocks: readonly AssistantBlock[]
|
||||
streaming: boolean
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */
|
||||
/** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
|
||||
interrupted?: boolean | undefined
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
return (
|
||||
<ToolRow
|
||||
variant="think"
|
||||
icon={<IconThinkOutline14 />}
|
||||
icon={<IconThinkOutline14 size={14} />}
|
||||
title="Think"
|
||||
summary={firstLine(text)}
|
||||
body={text}
|
||||
@@ -58,7 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
|
||||
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
|
||||
}
|
||||
})}
|
||||
{streaming && <span className={css.pulse} />}
|
||||
{interrupted && <span className={css.stopped}>已停止</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma);
|
||||
tool rows inside a group gap 10. Input padding cap rides the skeleton. */
|
||||
/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
|
||||
runs) via the column gap and between consecutive tool rows via the group
|
||||
gap. Input padding cap rides the skeleton. */
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
@@ -30,7 +31,7 @@
|
||||
.toolGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.callRow {
|
||||
@@ -51,6 +52,35 @@
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
|
||||
right with a stepped trail — flat keyframe holds, no tweening. Phase
|
||||
offsets come from per-rect animation-delay (index * -250ms) set inline
|
||||
by the component. */
|
||||
.turnDots {
|
||||
align-self: flex-start;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* One message line box: the dots center inside the text line height. */
|
||||
height: 26px;
|
||||
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
|
||||
is the 500 step, not this 450). */
|
||||
color: var(--dsw-static-deepseek-450);
|
||||
}
|
||||
|
||||
.turnDotCell {
|
||||
fill: currentColor;
|
||||
opacity: 0.15;
|
||||
animation: dsh-turn-dots-chase 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes dsh-turn-dots-chase {
|
||||
0%, 24.9% { opacity: 1; }
|
||||
25%, 49.9% { opacity: 0.6; }
|
||||
50%, 74.9% { opacity: 0.35; }
|
||||
75%, 100% { opacity: 0.15; }
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -25,7 +25,6 @@ import type {
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import type { SelectionTarget } from '../contract/views.ts'
|
||||
import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -36,7 +35,7 @@ import css from './ChatView.module.css'
|
||||
|
||||
const FOLLOW_THRESHOLD = 24
|
||||
|
||||
type OpenDetails = (target: SelectionTarget) => void
|
||||
type OpenFile = (path: string) => void
|
||||
|
||||
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
|
||||
type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
@@ -49,19 +48,18 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: CodeSubCall
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const seq = settled ? node.seq : node.time
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
|
||||
}), [node, toolName, seq, onOpenDetails])
|
||||
callId: node.callId, toolName, block: node, openFile, cwd,
|
||||
}), [node, toolName, openFile, cwd])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -77,25 +75,26 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
|
||||
* GenericToolCard at this render site. A `run_code` call additionally
|
||||
* renders its logged sub-dispatches as always-visible indented rows —
|
||||
* each one the same keyed-slot dispatch as a native top-level call. */
|
||||
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: {
|
||||
const CallRow = memo(function CallRow({
|
||||
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
|
||||
}: {
|
||||
renderSlot: RenderToolRow
|
||||
callId: string
|
||||
toolName: string
|
||||
block: ToolResultNode | RunningToolCall
|
||||
/** Surface seq for finalized results; the call's turn for running calls. */
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per
|
||||
* parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
const owner = useMemo(() => ({
|
||||
callId, toolName, block,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
|
||||
}), [callId, toolName, block, seq, onOpenDetails])
|
||||
callId, toolName, block, openFile, cwd,
|
||||
}), [callId, toolName, block, openFile, cwd])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -109,8 +108,9 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
key={node.callId}
|
||||
renderSlot={renderSlot}
|
||||
node={node}
|
||||
onOpenDetails={onOpenDetails}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -119,15 +119,17 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
)
|
||||
})
|
||||
|
||||
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: {
|
||||
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
|
||||
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
|
||||
renderSlot: RenderToolRow
|
||||
results: readonly ToolResultNode[]
|
||||
onOpenDetails: OpenDetails
|
||||
openFile: OpenFile
|
||||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||||
selectedCallId: string | undefined
|
||||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
/** Session workspace root for path-relative summaries. */
|
||||
cwd: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
@@ -138,17 +140,51 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
callId={node.callId}
|
||||
toolName={node.call?.name ?? ''}
|
||||
block={node}
|
||||
seq={node.seq}
|
||||
onOpenDetails={onOpenDetails}
|
||||
openFile={openFile}
|
||||
selected={node.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(node.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
|
||||
* 2px cell, same blue) chasing left to right with a stepped trail — flat
|
||||
* keyframe holds, no tweening, no rotation. Phase offsets come from
|
||||
* per-rect animation-delay. */
|
||||
const LOADER_CELLS = [0, 5, 10, 15] as const
|
||||
|
||||
function TurnDots() {
|
||||
return (
|
||||
/* The wrapper is a 26px line box (message line height) so the loader
|
||||
occupies one text line and centers the dots inside it. */
|
||||
<div className={css.turnDots} aria-hidden="true">
|
||||
<svg
|
||||
width="17.5"
|
||||
height="2.5"
|
||||
viewBox="0 0 17.5 2.5"
|
||||
shapeRendering="crispEdges"
|
||||
>
|
||||
{LOADER_CELLS.map((x, index) => (
|
||||
<rect
|
||||
key={x}
|
||||
className={css.turnDotCell}
|
||||
x={x}
|
||||
y="0"
|
||||
width="2.5"
|
||||
height="2.5"
|
||||
/* Negative delay phases the chase so every cell animates from mount. */
|
||||
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The streaming partial, isolated so chunk batches re-render only this tail.
|
||||
* onGrow lets the scroll owner follow content the parent never re-renders for. */
|
||||
function StreamingTail({ useSession, onGrow }: {
|
||||
@@ -167,8 +203,11 @@ function StreamingTail({ useSession, onGrow }: {
|
||||
* The chat view slot entry: pure component over the composed props (tool rows
|
||||
* render through the declared keyed hole's renderSlot share).
|
||||
*/
|
||||
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
const nodes = useSession(s => s.nodes)
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const running = useSession(s => s.running)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const pending = useSession(s => s.pending)
|
||||
@@ -265,9 +304,10 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
key={item.key}
|
||||
renderSlot={renderSlot}
|
||||
results={item.results}
|
||||
onOpenDetails={openDetails}
|
||||
openFile={openFile}
|
||||
selectedCallId={inGroup ? selectedCallId : undefined}
|
||||
codeDispatches={codeDispatches}
|
||||
cwd={cwd}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -304,16 +344,19 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
|
||||
callId={call.callId}
|
||||
toolName={call.name}
|
||||
block={call}
|
||||
seq={call.turn}
|
||||
onOpenDetails={openDetails}
|
||||
openFile={openFile}
|
||||
selected={call.callId === selectedCallId}
|
||||
subCalls={codeDispatches.get(call.callId)}
|
||||
selectedCallId={selectedCallId}
|
||||
cwd={cwd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pending.map(item => <PendingCard key={item.key} item={item} />)}
|
||||
{/* Turn-level loading signal: rides the whole running turn (first-token
|
||||
wait, tool execution, streaming) so it never flickers per step. */}
|
||||
{running && <TurnDots />}
|
||||
</div>
|
||||
</div>
|
||||
<StatsLine useSession={useSession} />
|
||||
|
||||
@@ -13,20 +13,21 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import { IconSparkle16 } from './IconSparkle16.tsx'
|
||||
|
||||
/** Variant leading icons (figma table). */
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
think: <IconThinkOutline14 />,
|
||||
search: <IconSearchOutline16 />,
|
||||
read: <IconBrowseOutline16 />,
|
||||
bash: <IconApiOutline14 size={16} />,
|
||||
write: <IconEditOutline16 />,
|
||||
edit: <IconEditOutline16 />,
|
||||
code: <IconCodeOutline16 />,
|
||||
others: <IconSparkle16 />,
|
||||
think: <IconThinkOutline14 size={14} />,
|
||||
search: <IconSearchOutline16 size={14} />,
|
||||
read: <IconBrowseOutline16 size={14} />,
|
||||
bash: <IconApiOutline14 size={14} />,
|
||||
write: <IconEditOutline16 size={14} />,
|
||||
edit: <IconEditOutline16 size={14} />,
|
||||
code: <IconCodeOutline16 size={14} />,
|
||||
others: <IconSparkle16 size={14} />,
|
||||
}
|
||||
|
||||
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
@@ -34,9 +35,11 @@ export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerPr
|
||||
icon={VARIANT_ICONS[model.variant]}
|
||||
title={model.title}
|
||||
summary={model.summary}
|
||||
body={model.body}
|
||||
// Single-file tools never expose an args body — the path link is the only action.
|
||||
body={singleFile ? null : model.body}
|
||||
state={model.state}
|
||||
onOpenDetails={openDetails}
|
||||
filePath={model.filePath}
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,22 +7,47 @@
|
||||
}
|
||||
|
||||
.row {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row[data-clickable] {
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
|
||||
theme background at 60% — glides over the row content from off-left to
|
||||
off-right, washing glyphs and icon toward the background as it passes.
|
||||
ease-out with a 10% end hold gives each pass a beat before the next. */
|
||||
.root[data-state='running'] .row::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.row[data-clickable]:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
@keyframes dsh-tool-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* Expand-on-row (Think / code): pointer only — no row fill hover. */
|
||||
.row[data-expandable] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leading {
|
||||
position: relative; /* .chevronHover overlay anchor */
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -65,11 +90,36 @@ button.leading {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
|
||||
into a down chevron before the row is opened. The chevron overlays the
|
||||
icon cell absolutely so both can stay mounted for the opacity transition. */
|
||||
.iconIdle {
|
||||
display: inline-flex;
|
||||
opacity: 1;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.chevronHover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.row:hover .iconIdle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.row:hover .chevronHover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
@@ -92,6 +142,29 @@ button.leading {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
|
||||
.body {
|
||||
padding: 4px 0 4px 22px;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
|
||||
// 16px leading slot (state dot / tool icon, chevron when expanded) + title +
|
||||
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
|
||||
// separator dot + FILL-truncated summary. Expanded body is indented gray text;
|
||||
// no inline output (full results live in the details panel). Expand state is
|
||||
// component-local view state; row click hands the selection off to the owner.
|
||||
// component-local view state. File-tool summaries are path links that open
|
||||
// through the host; the row itself is not a details-panel control.
|
||||
|
||||
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -24,15 +25,20 @@ export interface ToolRowProps {
|
||||
state: ToolRowState
|
||||
/** Makes the row itself the expand control instead of only its leading icon. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Selection handoff (row click), already bound to this call by the owner. */
|
||||
onOpenDetails?: (() => void) | undefined
|
||||
/**
|
||||
* Filesystem path from tool args; when set with onOpenFile, the summary
|
||||
* renders as a hover-underline link that opens the host default app.
|
||||
*/
|
||||
filePath?: string | undefined
|
||||
/** Open the path with the host OS default application (already cwd-resolved). */
|
||||
onOpenFile?: ((path: string) => void) | undefined
|
||||
}
|
||||
|
||||
/** Leading-slot state substitution: the tool icon yields to the state semantic
|
||||
* (running = blue ring, error = red, interrupted = amber halo; ok = icon). */
|
||||
/** Leading-slot state substitution: the tool icon yields to the terminal state
|
||||
* semantic (error = red, interrupted = amber halo). Running keeps the icon —
|
||||
* the row sweep (CSS on data-state) carries the in-flight signal. */
|
||||
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return icon
|
||||
@@ -48,10 +54,15 @@ export function ToolRow({
|
||||
body,
|
||||
state,
|
||||
expandOnRowClick = false,
|
||||
onOpenDetails,
|
||||
filePath,
|
||||
onOpenFile,
|
||||
}: ToolRowProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandable = body !== null
|
||||
// A row that names a single file keeps one interaction (open that path);
|
||||
// args expand is off whether or not the open callback is wired yet.
|
||||
const singleFile = filePath !== undefined
|
||||
const fileLink = singleFile && onOpenFile !== undefined
|
||||
const expandable = body !== null && !singleFile
|
||||
const open = expanded && expandable
|
||||
const rowExpands = expandable && expandOnRowClick
|
||||
const toggleExpand = () => {
|
||||
@@ -66,15 +77,32 @@ export function ToolRow({
|
||||
event.preventDefault()
|
||||
toggleExpand()
|
||||
}
|
||||
const openFile = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
if (filePath !== undefined) onOpenFile?.(filePath)
|
||||
}
|
||||
// Expandable rows preview the toggle on hover: the tool icon yields to a
|
||||
// down chevron (CSS swap on .row:hover); state dots still take precedence.
|
||||
const collapsedIcon = expandable
|
||||
? (
|
||||
<>
|
||||
<span className={css.iconIdle}>{icon}</span>
|
||||
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
|
||||
</>
|
||||
)
|
||||
: icon
|
||||
const leading = open
|
||||
? <IconChevronDownOutline14 className={css.chevron} />
|
||||
: leadingFor(state, collapsedIcon)
|
||||
return (
|
||||
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
|
||||
<div
|
||||
className={css.row}
|
||||
data-clickable={rowExpands || onOpenDetails !== undefined || undefined}
|
||||
data-expandable={rowExpands || undefined}
|
||||
role={rowExpands ? 'button' : undefined}
|
||||
tabIndex={rowExpands ? 0 : undefined}
|
||||
aria-expanded={rowExpands ? open : undefined}
|
||||
onClick={rowExpands ? toggleExpand : onOpenDetails}
|
||||
onClick={rowExpands ? toggleExpand : undefined}
|
||||
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
|
||||
>
|
||||
{expandable && !rowExpands ? (
|
||||
@@ -84,18 +112,28 @@ export function ToolRow({
|
||||
aria-expanded={open}
|
||||
onClick={toggleFromLeading}
|
||||
>
|
||||
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
|
||||
{leading}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.leading}>
|
||||
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)}
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<span className={css.title}>{title}</span>
|
||||
{!open && (
|
||||
<>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{summary}</span>
|
||||
{fileLink ? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.fileLink}
|
||||
onClick={openFile}
|
||||
>
|
||||
{summary}
|
||||
</button>
|
||||
) : (
|
||||
<span className={css.summary}>{summary}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,16 @@ export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
|
||||
/** An assistant node that renders nothing: only tool-call heads (rows render
|
||||
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
|
||||
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
|
||||
* always render (the 已停止 marker). */
|
||||
function rendersNothing(node: ConversationNode): boolean {
|
||||
return node.kind === 'assistant' && node.interrupted !== true
|
||||
&& node.blocks.every(b => b.kind === 'tool-call'
|
||||
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Group finalized nodes into the step-summary flow.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
@@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
|
||||
const items: ChatFlowItem[] = []
|
||||
let group: ToolResultNode[] | null = null
|
||||
for (const node of nodes) {
|
||||
if (rendersNothing(node)) continue
|
||||
if (node.kind === 'tool-result') {
|
||||
if (group === null) {
|
||||
group = [node]
|
||||
|
||||
@@ -143,8 +143,13 @@ export interface ToolRowOwnerProps {
|
||||
toolName: string
|
||||
/** Frozen call slice: the running call or the settled result node. */
|
||||
block: ToolCallBlock
|
||||
/** Open the details panel for this call (session-level facility, supplied by the view). */
|
||||
openDetails: () => void
|
||||
/** Session workspace root; path summaries display relative to it. */
|
||||
cwd?: string | undefined
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application.
|
||||
* The chat view resolves relative paths against the session cwd.
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,6 +288,11 @@ export type ConversationSessionSlotProps =
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails: (target: SelectionTarget) => void
|
||||
/**
|
||||
* Open a tool-arg filesystem path with the host OS default application
|
||||
* (relative paths resolve against the session cwd).
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
}
|
||||
|
||||
@@ -307,6 +317,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
|
||||
export interface EmptyWorkspaceOwnerProps {
|
||||
open: boolean
|
||||
anchorRef?: RefObject<HTMLElement>
|
||||
/** Currently active workspace (renders a trailing check in the picker list). */
|
||||
selectedId?: WorkspaceId | undefined
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface ToolRowModel {
|
||||
variant: ToolRowVariant
|
||||
title: string
|
||||
summary: string
|
||||
/**
|
||||
* Filesystem path from args (`path` / `file_path`) when the row is a file
|
||||
* tool; absent for URL reads and non-file tools. The chat view resolves
|
||||
* relative values against the session cwd before opening.
|
||||
*/
|
||||
filePath: string | undefined
|
||||
/** Expanded-body text (pretty args); null = row not expandable. */
|
||||
body: string | null
|
||||
state: ToolRowState
|
||||
@@ -101,6 +107,14 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
|
||||
others: [],
|
||||
}
|
||||
|
||||
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
|
||||
function relativizeToCwd(text: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return text
|
||||
const root = cwd.replace(/[/\\]+$/, '')
|
||||
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
|
||||
return text
|
||||
}
|
||||
|
||||
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
|
||||
@@ -113,6 +127,35 @@ function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
|
||||
return firstLine(argsRaw)
|
||||
}
|
||||
|
||||
/** Path keys only — never `url` (web_fetch lands on the read variant). */
|
||||
const FILE_PATH_KEYS = ['path', 'file_path'] as const
|
||||
|
||||
/** File-tool variants whose summary may be an openable workspace path. */
|
||||
const FILE_PATH_VARIANTS: ReadonlySet<ToolRowVariant> = new Set(['read', 'write', 'edit'])
|
||||
|
||||
function deriveFilePath(variant: ToolRowVariant, argsRaw: string): string | undefined {
|
||||
if (!FILE_PATH_VARIANTS.has(variant)) return undefined
|
||||
const parsed = parseArgs(argsRaw)
|
||||
if (typeof parsed !== 'object' || parsed === null) return undefined
|
||||
const picked = pickString(parsed as Record<string, unknown>, FILE_PATH_KEYS)
|
||||
return picked === undefined ? undefined : firstLine(picked)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool-arg path against the session cwd for host.openPath.
|
||||
* Absolute POSIX/Windows paths pass through; relative paths join under cwd.
|
||||
* @param cwd - session working directory (may be absent for ungrouped sessions).
|
||||
* @param path - path as carried in tool args.
|
||||
* @returns a host-facing path string.
|
||||
*/
|
||||
export function resolveToolPath(cwd: string | undefined, path: string): string {
|
||||
if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
|
||||
if (cwd === undefined || cwd === '') return path
|
||||
const base = cwd.replace(/[/\\]+$/, '')
|
||||
const rel = path.replace(/^[/\\]+/, '')
|
||||
return `${base}/${rel}`
|
||||
}
|
||||
|
||||
function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
if (argsRaw === '') return null
|
||||
const parsed = parseArgs(argsRaw)
|
||||
@@ -130,16 +173,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
|
||||
* Derive the full row model from a frozen call slice.
|
||||
* @param toolName - wire tool name (dispatch-supplied; survives windowless results).
|
||||
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
|
||||
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
|
||||
* @returns the row model.
|
||||
*/
|
||||
export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel {
|
||||
export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
|
||||
const variant = classifyTool(toolName)
|
||||
const done = 'kind' in block
|
||||
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const state: ToolRowState = !done ? 'running'
|
||||
: block.error?.code === 'interrupted' ? 'stopped'
|
||||
: block.isError ? 'error' : 'ok'
|
||||
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw)
|
||||
const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
|
||||
const toolTitle = TOOL_TITLES[toolName]
|
||||
// Others keeps the static "Tool call" title (figma literal); the real tool
|
||||
// name rides the mutable summary slot unless the tool owns a specific title.
|
||||
@@ -150,6 +194,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowMod
|
||||
variant,
|
||||
title: toolTitle ?? VARIANT_TITLES[variant],
|
||||
summary,
|
||||
filePath: deriveFilePath(variant, argsRaw),
|
||||
body: deriveBody(variant, argsRaw),
|
||||
state,
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -72,13 +72,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
@@ -87,7 +80,7 @@
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */
|
||||
/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
|
||||
.tab {
|
||||
position: relative;
|
||||
padding: 0 0 11px;
|
||||
@@ -95,7 +88,7 @@
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
font-weight: 510;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -139,11 +132,45 @@
|
||||
NOT absolute+transform: a transform would make this box the containing
|
||||
block for position:fixed descendants (pickers/modals), shrinking them. */
|
||||
.composerHero {
|
||||
position: relative; /* .heroGlow positioning context */
|
||||
align-self: center;
|
||||
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
|
||||
gap: 12px;
|
||||
/* Foot inside the centered box floats the stack a bit above true center. */
|
||||
padding-bottom: 32px;
|
||||
width: min(776px, calc(100% - 48px));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the
|
||||
card's resting center sits ~92px above the stack bottom (32 foot pad +
|
||||
half of the ~120px two-row card); width tracks the card (glow asset 1051
|
||||
vs design card 776) so blur scales in userSpace with it. z-index -1 keeps
|
||||
it behind the in-flow hero content inside this stacking context. */
|
||||
.heroGlow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 92px;
|
||||
z-index: -1;
|
||||
width: calc(100% * 1051 / 776);
|
||||
aspect-ratio: 1051 / 468;
|
||||
transform: translate(-50%, 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heroWorkspaceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.root[data-phase='hero'] {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Settling (session replaying, hero/docked unknown): keep the composer
|
||||
mounted but invisible so no wrong layout flashes before the phase lands. */
|
||||
.root[data-phase='settling'] .composerStack {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
|
||||
import { DisabledInputBar } from './DisabledInputBar.tsx'
|
||||
import css from './ConversationRoot.module.css'
|
||||
|
||||
@@ -36,33 +36,53 @@ export function ConversationRoot({
|
||||
workspace => workspace.workspaceId === pendingWorkspaceId,
|
||||
)
|
||||
|
||||
// Clear the pending pick once the session lands in it, or when the picked
|
||||
// workspace disappears from a ready list (deleted from the sidebar).
|
||||
useEffect(() => {
|
||||
if (pendingWorkspaceId !== undefined
|
||||
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) {
|
||||
if (pendingWorkspaceId === undefined) return
|
||||
if (sessionWorkspace?.workspaceId === pendingWorkspaceId
|
||||
|| (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
|
||||
setPendingWorkspaceId(undefined)
|
||||
}
|
||||
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId])
|
||||
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
|
||||
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading'))
|
||||
// While a session is still replaying (loading + blank) the hero/docked
|
||||
// choice is unknowable — render the composer hidden instead of flashing
|
||||
// the centered hero and snapping to the docked bar (or vice versa).
|
||||
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
|
||||
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
|
||||
const zone: InputZone | undefined =
|
||||
session === undefined || inputState === undefined ? undefined : { session, input: inputState }
|
||||
|
||||
// Flow optimization — worth a close PR review for code/boundary issues.
|
||||
// The chip is a selector; label resolution walks the flow top-down:
|
||||
// 1. a just-picked workspace (pending) → its title;
|
||||
// 2. cold start, no session yet → placeholder ("Choose workspace");
|
||||
// 3. the blank session's workspace is in the list → its title;
|
||||
// 4. list still loading → cwd folder name bridges so the title does not
|
||||
// flash on refresh (empty cwd → placeholder);
|
||||
// 5. list ready but no owning workspace (deleted from the sidebar) →
|
||||
// placeholder, never the deleted folder's name via cwd.
|
||||
const chipTitle = pendingWorkspace?.title
|
||||
?? (sessionId === undefined
|
||||
? undefined
|
||||
: sessionWorkspace?.title
|
||||
?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
|
||||
? undefined
|
||||
: workspaceLabel(cwd)))
|
||||
|
||||
const heroWorkspaceRow = (
|
||||
<>
|
||||
<div className={css.heroWorkspaceRow}>
|
||||
<WorkspaceChip
|
||||
buttonRef={pickerAnchor}
|
||||
label={
|
||||
pendingWorkspace?.title
|
||||
?? (sessionId === undefined
|
||||
? workspaceLabel('')
|
||||
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
|
||||
}
|
||||
label={chipTitle}
|
||||
menuOpen={pickerOpen}
|
||||
onClick={() => { setPickerOpen(open => !open) }}
|
||||
/>
|
||||
{renderSlot('conversation.hero.workspace', {
|
||||
open: pickerOpen,
|
||||
anchorRef: pickerAnchor,
|
||||
selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
|
||||
onPick: (workspaceId) => {
|
||||
setPickerOpen(false)
|
||||
setPendingWorkspaceId(workspaceId)
|
||||
@@ -72,10 +92,13 @@ export function ConversationRoot({
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
|
||||
const inputBar = sessionId === undefined
|
||||
// The placeholder chip ("Choose workspace") and the inert input travel
|
||||
// together: a blank session whose workspace vanished (deleted from the
|
||||
// sidebar) reverts to the same disabled bar as the initial no-session state.
|
||||
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
|
||||
? <DisabledInputBar />
|
||||
: renderSlot('conversation.composer.bar', {
|
||||
variant: hero ? 'hero' : 'composer',
|
||||
@@ -87,6 +110,7 @@ export function ConversationRoot({
|
||||
|
||||
const composerBar = (
|
||||
<div className={clsx(css.composerStack, hero && css.composerHero)}>
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
@@ -96,7 +120,7 @@ export function ConversationRoot({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={css.root} data-phase={hero ? 'hero' : 'active'}>
|
||||
<div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
|
||||
{/* Mounted for every real session, hero included: ConversationSession
|
||||
renders no chrome while blank but owns the draft-persistence mirror
|
||||
bind — unmounting it in the hero would lose pre-first-send text on
|
||||
|
||||
@@ -31,7 +31,6 @@ export function ConversationSession({
|
||||
const activeId = useStore(s => s.view) ?? 'chat'
|
||||
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
||||
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
|
||||
const turns = useSession(s => countTurns(s))
|
||||
const composerPhase = useSession(s => s.composerPhase)
|
||||
const blank = useSession(s => s.blank)
|
||||
const inputState = useInput(s => s)
|
||||
@@ -69,7 +68,6 @@ export function ConversationSession({
|
||||
)
|
||||
})}
|
||||
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
||||
<span className={css.meta}>· {turns} turns</span>
|
||||
</nav>
|
||||
</div>
|
||||
{tabs.length > 1 && (
|
||||
@@ -95,9 +93,3 @@ export function ConversationSession({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number {
|
||||
let count = 0
|
||||
for (const node of snapshot.nodes) if (node.kind === 'user') count += 1
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DisabledInputBar() {
|
||||
<div className={css.trailing}>
|
||||
<button type="button" className={css.primary} aria-label="Send message" disabled>
|
||||
<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" />
|
||||
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,20 +7,18 @@
|
||||
import { useId } from 'react'
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import {
|
||||
FishLogo, IconChevronDownOutline14, IconFolderOpen16,
|
||||
FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import css from './HeroShell.module.css'
|
||||
|
||||
/**
|
||||
* Basename label for the workspace chip / menu rows (the shared derivation);
|
||||
* empty → the design's "New Workspace" placeholder copy; separator-only
|
||||
* paths echo the raw cwd.
|
||||
* @param cwd - workspace directory path ('' for none).
|
||||
* Basename label for the workspace chip (the shared derivation);
|
||||
* separator-only paths echo the raw cwd.
|
||||
* @param cwd - workspace directory path (non-empty).
|
||||
* @returns chip label.
|
||||
*/
|
||||
export function workspaceLabel(cwd: string): string {
|
||||
if (cwd === '') return 'New Workspace'
|
||||
const base = workspaceTitleOf(cwd)
|
||||
return base !== '' ? base : cwd
|
||||
}
|
||||
@@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string {
|
||||
/**
|
||||
* The workspace chip (folder + label + chevron), always interactive: before
|
||||
* the first message the workspace stays switchable — picking another one
|
||||
* moves the New Session flow to that workspace's blank session.
|
||||
* @param props.label - chip label (see {@link workspaceLabel}).
|
||||
* moves the New Session flow to that workspace's blank session. Without a
|
||||
* label the chip renders its placeholder state: closed folder + the
|
||||
* "Choose workspace" call to action.
|
||||
* @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder.
|
||||
* @param props.menuOpen - menu expansion echo.
|
||||
* @param props.onClick - menu toggle.
|
||||
* @returns the chip button element.
|
||||
*/
|
||||
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
|
||||
buttonRef?: RefObject<HTMLButtonElement>
|
||||
label: string
|
||||
label?: string | undefined
|
||||
menuOpen?: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
@@ -50,13 +50,49 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
|
||||
aria-expanded={menuOpen}
|
||||
onClick={onClick}
|
||||
>
|
||||
<IconFolderOpen16 className={css.folder} size={16} />
|
||||
<span className={css.workspaceLabel}>{label}</span>
|
||||
{label === undefined
|
||||
? <IconFolderClose16 className={css.folder} size={16} />
|
||||
: <IconFolderOpen16 className={css.folder} size={16} />}
|
||||
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
|
||||
<IconChevronDownOutline14 className={css.chevron} size={12} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
|
||||
* owner (ConversationRoot), not HeroShell, so it can center on the input
|
||||
* card; the owner's className supplies all positioning.
|
||||
* @param props.className - positioning class from the owner.
|
||||
* @returns the blurred-ellipse svg element.
|
||||
*/
|
||||
export function HeroGlow({ className }: { className?: string | undefined }) {
|
||||
// Stable filter id so multiple hero mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
return (
|
||||
<svg className={className} 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.08" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
|
||||
export interface HeroShellProps {
|
||||
/** Overlay content after the stack (modals). */
|
||||
@@ -64,13 +100,12 @@ export interface HeroShellProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hero chrome (headline + glow; no composer, no workspace row).
|
||||
* Render the hero chrome (headline only; no glow, no composer, no workspace
|
||||
* row — the glow is the owner's {@link HeroGlow}).
|
||||
* @param props - see {@link HeroShellProps}.
|
||||
* @returns the centered hero element tree.
|
||||
*/
|
||||
export function HeroShell({ children }: HeroShellProps) {
|
||||
// Stable filter id so multiple hero mounts do not collide in the DOM.
|
||||
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
|
||||
return (
|
||||
<div className={css.root}>
|
||||
<div className={css.stack}>
|
||||
@@ -80,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) {
|
||||
Let's start building
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{/* figma 313:14109: soft ellipse behind workspace + composer; 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>
|
||||
{/* The resident composer (rendered by ConversationRoot at its stable
|
||||
tree position; the workspace row rides its accessory hole) is
|
||||
CSS-positioned into this gap during the hero phase — see
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
margin-bottom: -70px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
|
||||
@@ -24,17 +23,15 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block
|
||||
keeps 36px below the headline before the flex gap. */
|
||||
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
|
||||
.headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 36px;
|
||||
font-size: 26px;
|
||||
line-height: 32px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -44,8 +41,9 @@
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
|
||||
glow lives with the owner (ConversationRoot .heroGlow) so it can center on
|
||||
the input card. */
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -55,19 +53,7 @@
|
||||
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) {
|
||||
.body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -88,7 +74,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: fit-content;
|
||||
max-width: min(100%, 360px);
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
|
||||
@@ -171,6 +171,10 @@
|
||||
.input,
|
||||
.mirror,
|
||||
.backdrop {
|
||||
/* Textareas default to content-box (unlike buttons/inputs): without this the
|
||||
width:100% textarea gains its padding OUTSIDE the card and text runs past
|
||||
the right padding — and wraps 28px later than the mirror/backdrop layers. */
|
||||
box-sizing: border-box;
|
||||
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
|
||||
metrics or the highlight ranges drift off the glyphs. */
|
||||
padding: 4px 12px 0 16px;
|
||||
@@ -306,11 +310,14 @@
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-button-info-fill);
|
||||
color: var(--dsw-alias-label-primary-foreground);
|
||||
/* Static white, not the foreground token: the arrow stays white on the blue
|
||||
fill in both themes (design 34:10465). */
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.primary:hover {
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-info-hover);
|
||||
}
|
||||
|
||||
@@ -319,14 +326,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Stop state: same slot, dimmed brand fill — the running-state send-key
|
||||
replacement is a design gap filled by us (figma gives no stop form). */
|
||||
.stopping,
|
||||
.stopping:hover {
|
||||
background: var(--dsw-alias-button-primary-dimmed);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.retry {
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
|
||||
@@ -372,7 +372,7 @@ export function InputBar({
|
||||
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.primary, running && css.stopping)}
|
||||
className={css.primary}
|
||||
aria-label={primaryLabel}
|
||||
title={primaryLabel}
|
||||
disabled={!running && (empty || disabled || machineBusy)}
|
||||
@@ -381,11 +381,11 @@ export function InputBar({
|
||||
>
|
||||
{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" />
|
||||
<rect x="3" y="3" width="10" height="10" rx="3" 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" />
|
||||
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -92,15 +92,15 @@
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.glyphPending {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.glyphProgress {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
animation: todo-progress-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.glyphPending {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes todo-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
|
||||
.root {
|
||||
position: relative; /* sweep-glare overlay anchor */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.root:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
|
||||
.root[data-state='running']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 300px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes dsh-bash-row-sweep {
|
||||
0% { left: -300px; }
|
||||
90%, 100% { left: 100%; }
|
||||
}
|
||||
|
||||
.leading {
|
||||
@@ -39,7 +58,7 @@
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sep {
|
||||
|
||||
@@ -12,10 +12,10 @@ import css from './bash-sample.module.css'
|
||||
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconApiOutline14 size={16} />
|
||||
// Running keeps the icon — the row sweep carries the in-flight signal.
|
||||
default: return <IconApiOutline14 size={14} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ function stateStatus(state: ToolRowState): string | null {
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
const status = stateStatus(model.state)
|
||||
@@ -40,8 +40,6 @@ export function BashRow({ toolName, block, openDetails, sessionId, useSessions }
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-clickable
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.leading {
|
||||
@@ -29,6 +23,7 @@
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500; /* figma wt510, rendered 500 */
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
// ellipsized text; the durable list itself renders in the TodoPanel above the
|
||||
// composer, so the row stays one line. Chrome matches ToolRow (figma 780:53675).
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { IconChecklistOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
@@ -61,29 +60,18 @@ function leadingFor(state: ToolRowState) {
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row (click opens the raw args in details). Non-ok
|
||||
* execution states keep the generic row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
|
||||
/** One-line plan update row. Non-ok execution states keep the generic row's
|
||||
* dot semantics — a cancelled call wrote no todo/write, so it must not read
|
||||
* as a completed update. */
|
||||
export function TodoRow({ toolName, block }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw) ?? { text: model.summary, extra: 0 }
|
||||
// Button semantics, not a <button>: the row carries inline spans a button
|
||||
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
|
||||
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
openDetails()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={css.row}
|
||||
data-sample="todo-row"
|
||||
data-state={model.state}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openDetails}
|
||||
onKeyDown={openFromKeyboard}
|
||||
>
|
||||
<span className={css.leading} aria-hidden>{leadingFor(model.state)}</span>
|
||||
<span className={css.title}>更新任务清单</span>
|
||||
|
||||
@@ -107,6 +107,7 @@ async function bench() {
|
||||
const workspacesFake = {
|
||||
list: workspaceStore,
|
||||
connectWorkspace: vi.fn(async () => ROOT),
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspacesFake)
|
||||
const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
|
||||
@@ -259,6 +260,15 @@ describe('conversation slot inject surface', () => {
|
||||
expect(conv.instance).toBe(instance)
|
||||
})
|
||||
|
||||
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
injected.openFile('src/a.ts')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspacesFake.openPath).toHaveBeenCalledWith('/proj/src/a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
it('routes navigation and workspace switching through the runtime owners, carrying the draft', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
|
||||
@@ -48,6 +48,7 @@ async function bench() {
|
||||
ctx.provide('workspaces', {
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// always-visible nested rows through the SAME keyed toolview hole — the bash
|
||||
// sub-call lands in the bash sample plugin's registration exactly like a
|
||||
// top-level bash row, unregistered sub-tools fall back to GenericToolCard —
|
||||
// and a sub-row click opens details for the sub-callId. Running parents
|
||||
// and a file sub-row click opens the host path. Running parents
|
||||
// (runningCalls) nest their so-far dispatches the same way.
|
||||
|
||||
import { Context } from 'cordis'
|
||||
@@ -114,14 +114,16 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
open: vi.fn(),
|
||||
}
|
||||
ctx.provide('sessions', sessionsFake)
|
||||
ctx.provide('workspaces', {
|
||||
const workspaces = {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('i18n', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -136,7 +138,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, layout }
|
||||
return { ctx, slots, fiber, session, layout, workspaces }
|
||||
}
|
||||
|
||||
function mountApp(slots: SlotsService) {
|
||||
@@ -220,15 +222,21 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(nested).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a sub-row click opens details for the sub-callId', async () => {
|
||||
it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
|
||||
const parent = 'call-64'
|
||||
const dispatches = new Map([[parent, [
|
||||
subCall(11, parent, 1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
|
||||
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('notes/demo.txt').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
|
||||
})
|
||||
view.getByText('List notes').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {
|
||||
@@ -255,7 +263,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
|
||||
const view = mountApp(b.slots)
|
||||
// The nested row derives 'running' from the RunningToolCall shape — the
|
||||
// same StateDot ring a native in-flight row wears.
|
||||
// same data-state chrome (row sweep) a native in-flight row wears.
|
||||
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
|
||||
expect(nested).not.toBeNull()
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// standard useSessions kit (no registry predicates — tool ring dissolved).
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import type {
|
||||
AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -133,10 +133,9 @@ describe('bash sample row', () => {
|
||||
|
||||
const rowProps = (sessionId: SessionId, over?: {
|
||||
store?: ReturnType<typeof listStore>
|
||||
openDetails?: () => void
|
||||
}): ToolRowProps => ({
|
||||
callId: 'c1', toolName: 'bash', block: result('c1'),
|
||||
openDetails: over?.openDetails ?? vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
sessionId,
|
||||
useSessions: bindSnapshotSelector(over?.store ?? listStore()),
|
||||
} as unknown as ToolRowProps)
|
||||
@@ -169,21 +168,17 @@ describe('bash sample row', () => {
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
|
||||
const openGlobal = vi.fn()
|
||||
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
|
||||
it('summarizes as Bash · description on both arms without row click targets', () => {
|
||||
const global = render(<BashRow {...rowProps(ROOT)} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Bash')
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
fireEvent.click(globalRow)
|
||||
expect(openGlobal).toHaveBeenCalledTimes(1)
|
||||
const openScoped = vi.fn()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
|
||||
expect(globalRow.getAttribute('data-clickable')).toBeNull()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD)} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Bash')
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
fireEvent.click(scopedRow)
|
||||
expect(openScoped).toHaveBeenCalledTimes(1)
|
||||
expect(scopedRow.getAttribute('data-clickable')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
|
||||
afterEach(cleanup)
|
||||
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { classifyTool, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
|
||||
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
|
||||
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
|
||||
@@ -64,6 +64,32 @@ describe('tool-call-model', () => {
|
||||
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
|
||||
})
|
||||
|
||||
it('exposes filePath for path/file_path args and skips URL-only reads', () => {
|
||||
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('write', running({ name: 'write', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"src/a.ts"}' })).filePath).toBe('src/a.ts')
|
||||
expect(toolRowModel('web_fetch', running({ name: 'web_fetch', argsRaw: '{"url":"https://example.com"}' })).filePath)
|
||||
.toBeUndefined()
|
||||
expect(toolRowModel('bash', running()).filePath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolveToolPath joins relative paths under cwd and passes absolute through', () => {
|
||||
expect(resolveToolPath('/w', 'src/a.ts')).toBe('/w/src/a.ts')
|
||||
expect(resolveToolPath('/w/', '/abs/a.ts')).toBe('/abs/a.ts')
|
||||
expect(resolveToolPath(undefined, 'src/a.ts')).toBe('src/a.ts')
|
||||
expect(resolveToolPath('/w', 'C:\\x\\a.ts')).toBe('C:\\x\\a.ts')
|
||||
})
|
||||
|
||||
it('displays workspace-rooted paths relative to the session cwd', () => {
|
||||
const cwd = '/Users/u/ws/'
|
||||
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
|
||||
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
|
||||
// Paths outside the workspace (and non-path summaries) stay verbatim.
|
||||
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
|
||||
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
|
||||
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
|
||||
})
|
||||
|
||||
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
|
||||
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
|
||||
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
|
||||
@@ -125,12 +151,12 @@ describe('ToolRow', () => {
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('running and error states replace the icon with a StateDot', () => {
|
||||
it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
|
||||
const runningView = render(<ToolRow {...rowProps} state="running" />)
|
||||
expect(runningView.queryByTestId('tool-icon')).toBeNull()
|
||||
expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
const errorView = render(<ToolRow {...rowProps} state="error" />)
|
||||
expect(errorView.queryByTestId('tool-icon')).toBeNull()
|
||||
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('non-expandable rows render a passive leading slot', () => {
|
||||
@@ -139,13 +165,34 @@ describe('ToolRow', () => {
|
||||
expect(view.queryByTestId('tool-icon')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click hands off to onOpenDetails; the expand toggle does not', () => {
|
||||
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenDetails={open} />)
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
|
||||
)
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(open).toHaveBeenCalledWith('src/a.ts')
|
||||
// Only the path link is a button — no args-expand affordance on file rows.
|
||||
expect(view.container.querySelectorAll('button')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
})
|
||||
|
||||
it('a single-file path disables expand even when onOpenFile is absent', () => {
|
||||
const view = render(
|
||||
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
|
||||
)
|
||||
expect(view.container.querySelector('button')).toBeNull()
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
fireEvent.click(view.getByText('作文.md'))
|
||||
expect(view.queryByText(/"a": 1/)).toBeNull()
|
||||
})
|
||||
|
||||
it('non-file rows do not open anything when the summary is clicked', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(<ToolRow {...rowProps} onOpenFile={open} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(view.container.querySelector('button')!)
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
expect(open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -170,7 +217,7 @@ describe('ThinkRow', () => {
|
||||
|
||||
describe('GenericToolCard', () => {
|
||||
const props = (toolName: string, block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName, block, openDetails: vi.fn(),
|
||||
callId: 'c1', toolName, block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
it('renders the classified variant row from the frozen slice', () => {
|
||||
@@ -215,10 +262,15 @@ describe('GenericToolCard', () => {
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('row click reaches openDetails', () => {
|
||||
const p = props('bash', result())
|
||||
const view = render(<GenericToolCard {...p} />)
|
||||
fireEvent.click(view.getByText('List files'))
|
||||
expect(p.openDetails).toHaveBeenCalledTimes(1)
|
||||
it('file-path summary click reaches openFile; bash summary does not', () => {
|
||||
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
|
||||
const fileView = render(<GenericToolCard {...file} />)
|
||||
fireEvent.click(fileView.getByText('src/x.ts'))
|
||||
expect(file.openFile).toHaveBeenCalledWith('src/x.ts')
|
||||
|
||||
const bash = props('bash', result())
|
||||
const bashView = render(<GenericToolCard {...bash} />)
|
||||
fireEvent.click(bashView.getByText('List files'))
|
||||
expect(bash.openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,14 +121,16 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
open: vi.fn(),
|
||||
updateIntent: vi.fn(),
|
||||
})
|
||||
ctx.provide('workspaces', {
|
||||
const workspaces = {
|
||||
list: createSnapshotStore<WorkspaceListState>({
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
})
|
||||
openPath: vi.fn(async () => {}),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -143,7 +145,7 @@ async function bench(nodes: ToolResultNode[]) {
|
||||
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, slots, fiber, session, list, layout }
|
||||
return { ctx, slots, fiber, session, list, layout, workspaces }
|
||||
}
|
||||
|
||||
/** Render the whole tree through the ctx-level root seam (the shell's own entry). */
|
||||
@@ -186,11 +188,22 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
|
||||
})
|
||||
|
||||
it('row clicks travel owner openDetails → chat inject → layout orchestration', async () => {
|
||||
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('src/a.ts').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspaces.openPath).toHaveBeenCalledWith('src/a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
it('bash summary clicks do not open details or host paths', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'bash')])
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('Build').click()
|
||||
expect(b.layout.openDetails).toHaveBeenCalledTimes(1)
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
expect(b.workspaces.openPath).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a live keyed registration takes over its tool row and unload reverts to the fallback', async () => {
|
||||
@@ -271,6 +284,7 @@ describe('registrant load-order seam', () => {
|
||||
}),
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
})
|
||||
ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
|
||||
ctx.provide('locale', { bind: () => (key: string) => key })
|
||||
|
||||
@@ -88,6 +88,7 @@ function emptyWorkspaces() {
|
||||
function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const { set, source } = makeSource(init)
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
@@ -112,10 +113,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
renderSlot,
|
||||
SessionProvider: SessionProviderStub,
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -131,6 +133,22 @@ describe('chat-flow derivation', () => {
|
||||
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
|
||||
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
|
||||
})
|
||||
|
||||
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
|
||||
// A tool-call-only step message (and blank text/reasoning) renders nothing:
|
||||
// it must not split the run into two groups with an empty line between.
|
||||
const headsOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
|
||||
}
|
||||
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
|
||||
expect(flowKeys(items)).toBe('g3')
|
||||
const group = items[0]!
|
||||
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
|
||||
// Interrupted and visible-content nodes still render (已停止 marker / prose).
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
@@ -265,16 +283,31 @@ describe('ChatView', () => {
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
|
||||
it('clicking a bash summary does not open details; selection still marks data-selected', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
expect(h.openDetails).toHaveBeenCalledWith({ turnSeq: 3, callId: 'a', toolName: 'bash' })
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
expect(h.openFile).not.toHaveBeenCalled()
|
||||
expect(view.container.querySelector('[data-selected]')).toBeNull()
|
||||
act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
|
||||
expect(view.container.querySelector('[data-selected]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('clicking a file-tool path summary opens the host file, not details', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [{
|
||||
kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
|
||||
call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
|
||||
callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
|
||||
}],
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('src/a.ts'))
|
||||
expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
|
||||
expect(h.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('running calls render as a live tool group with the running state', () => {
|
||||
const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('tails', () => {
|
||||
content: [], isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const props: ToolRowOwnerProps = {
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openDetails: vi.fn(),
|
||||
callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(),
|
||||
}
|
||||
const view = render(<GenericToolCard {...props} />)
|
||||
// Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
|
||||
@@ -90,7 +90,7 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
|
||||
it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
@@ -99,7 +99,7 @@ describe('tails', () => {
|
||||
phase: 'ready',
|
||||
})
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
|
||||
@@ -150,10 +150,10 @@ const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResult
|
||||
content: [], isError: false, callView: null, resultView: null, ...over,
|
||||
})
|
||||
|
||||
function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps {
|
||||
function rowProps(block: unknown): ToolRowProps {
|
||||
return {
|
||||
callId: 'c1', toolName: 'todo_write', block,
|
||||
openDetails,
|
||||
openFile: vi.fn(),
|
||||
sessionId: 's1',
|
||||
useSessions: () => undefined,
|
||||
} as unknown as ToolRowProps
|
||||
@@ -203,27 +203,10 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('todo_write · not json')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back when parsed args carry no todos array, and click opens details', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'), openDetails)} />)
|
||||
it('falls back when parsed args carry no todos array and stays non-interactive', () => {
|
||||
render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
|
||||
expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
|
||||
fireEvent.click(screen.getByText('更新任务清单'))
|
||||
expect(openDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens details from the keyboard on Enter and Space, ignoring other keys', () => {
|
||||
const openDetails = vi.fn()
|
||||
render(<TodoRow {...rowProps(resultNode(ARGS), openDetails)} />)
|
||||
const row = screen.getByRole('button')
|
||||
expect(row.getAttribute('tabindex')).toBe('0')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
fireEvent.keyDown(row, { key: ' ' })
|
||||
expect(openDetails).toHaveBeenCalledTimes(2)
|
||||
// Space must not also scroll the flow: the handler claims the event.
|
||||
expect(fireEvent.keyDown(row, { key: ' ' })).toBe(false)
|
||||
fireEvent.keyDown(row, { key: 'a' })
|
||||
fireEvent.keyDown(row, { key: 'ArrowDown' })
|
||||
expect(openDetails).toHaveBeenCalledTimes(3)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -49,6 +49,8 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
|
||||
const chatProps = (props: ChatViewSlotProps): ReactNode => {
|
||||
// @ts-expect-error openDetails takes a SelectionTarget, not a string
|
||||
props.openDetails('nope')
|
||||
// @ts-expect-error openFile takes a path string, not a SelectionTarget
|
||||
props.openFile({ turnSeq: 1, callId: 'c' })
|
||||
return null
|
||||
}
|
||||
void chatProps
|
||||
|
||||
@@ -72,9 +72,11 @@
|
||||
max-height: min(360px, calc(100vh - 96px));
|
||||
overflow: hidden;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
/* Surface tokens match the Menu primitive card (ui-primitives
|
||||
* Menu.module.css) so every dropdown reads as the same material. */
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
background: var(--dsw-specific-menu);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
@@ -132,7 +134,7 @@
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 5px 8px 3px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
background: var(--dsw-specific-menu);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
@@ -156,11 +158,16 @@
|
||||
}
|
||||
|
||||
.option:hover:not(:disabled),
|
||||
.option:focus-visible,
|
||||
.selected {
|
||||
.option:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Selection marker is the trailing check, not a fill — matches the Menu
|
||||
* primitive's selected treatment. */
|
||||
.selected {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.option:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: default;
|
||||
@@ -201,7 +208,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 18px;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.md {
|
||||
@@ -44,10 +44,6 @@
|
||||
background: var(--dsw-alias-button-primary-hover);
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
background: var(--dsw-alias-button-primary-dimmed);
|
||||
}
|
||||
|
||||
.ghost:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
@@ -66,10 +62,6 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
min-width: 218px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
/* Portal mode: fixed in the viewport, coordinates supplied inline from the
|
||||
@@ -50,6 +51,36 @@
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
|
||||
* (24 = 2 × the portal MARGIN in Menu.tsx) and taller content scrolls inside
|
||||
* .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
|
||||
* this class — the overflow clip would crop the side card, so they rely on
|
||||
* staying short. */
|
||||
.scrollable {
|
||||
max-height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.viewport {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scrollable .viewport {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on
|
||||
* the menu surface) mirrors the .separator spacing. */
|
||||
.footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.itemWrap {
|
||||
position: relative;
|
||||
}
|
||||
@@ -78,7 +109,7 @@
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// The owner controls `open`; outside-click closing uses one document listener
|
||||
// active only while open. Submenus open on hover/focus inside the same root.
|
||||
// Entries also cover non-interactive `label` headings and `danger` rows.
|
||||
// Lists keep 12px clearance to the viewport's top/bottom edges and scroll
|
||||
// internally past that; submenu-bearing menus are exempt (see .scrollable).
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
@@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
|
||||
return 'type' in entry && entry.type === 'label'
|
||||
}
|
||||
|
||||
/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
|
||||
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
|
||||
/**
|
||||
* Render an anchored dropdown menu.
|
||||
* @param props.open - whether the list is showing (owner-controlled).
|
||||
@@ -72,17 +77,20 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
|
||||
* the trigger (render-prop anchors, effect-positioned proxies — measuring the
|
||||
* wrapper there races the host's layout effects). Called on open and on every
|
||||
* scroll/resize; return null to skip placement for that frame.
|
||||
* @param props.footer - rows pinned below the scrolling items area, separated
|
||||
* by a hairline; they stay visible while the items above scroll.
|
||||
* @returns anchor wrapper with the conditional list.
|
||||
*/
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
|
||||
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, footer, className }: {
|
||||
open: boolean
|
||||
anchor: ReactNode
|
||||
items: readonly MenuEntry[]
|
||||
selectedId?: string
|
||||
footer?: readonly MenuEntry[]
|
||||
selectedId?: string | undefined
|
||||
onSelect: (id: string) => void
|
||||
onClose: () => void
|
||||
align?: 'start' | 'end'
|
||||
side?: 'bottom' | 'top'
|
||||
side?: 'bottom' | 'top' | 'right'
|
||||
portal?: boolean
|
||||
closeOnPointerLeave?: boolean
|
||||
getAnchorRect?: () => DOMRect | null
|
||||
@@ -109,11 +117,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
r = rootRef.current?.getBoundingClientRect() ?? null
|
||||
}
|
||||
if (r === null) return
|
||||
setFixedPos({
|
||||
...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }),
|
||||
...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }),
|
||||
})
|
||||
const MARGIN = 12
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
const listEl = listRef.current
|
||||
const lw = listEl?.offsetWidth ?? 0
|
||||
const lh = listEl?.offsetHeight ?? 0
|
||||
|
||||
let x: number
|
||||
let y: number
|
||||
if (side === 'right') {
|
||||
x = r.right + 4
|
||||
y = r.top
|
||||
} else if (align === 'start') {
|
||||
x = r.left
|
||||
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
||||
} else {
|
||||
x = r.right - lw
|
||||
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
|
||||
}
|
||||
|
||||
if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
|
||||
if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN)
|
||||
|
||||
setFixedPos({ left: x, top: y })
|
||||
}
|
||||
// First run measures the hidden pre-render (same commit as `open`), so
|
||||
// end/top alignment and clamping use real dimensions before anything
|
||||
// paints — no visible jump from a zero-size first guess.
|
||||
place()
|
||||
window.addEventListener('scroll', place, true)
|
||||
window.addEventListener('resize', place)
|
||||
@@ -146,11 +177,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
const list = open && (!portal || fixedPos !== null) && (
|
||||
// The submenu card is absolutely positioned outside the list box; the
|
||||
// scroll clip would crop it, so only submenu-free menus get the height cap.
|
||||
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
|
||||
|
||||
const renderEntry = (entry: MenuEntry) => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
if (isLabel(entry)) {
|
||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||
}
|
||||
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, entry.danger === true && css.danger)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Portal lists render hidden until placed: the placement effect measures
|
||||
// this pre-render in the same commit, so the first painted frame is
|
||||
// already at the final position (with getAnchorRect returning null the
|
||||
// list simply stays hidden).
|
||||
const list = open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={fixedPos ?? undefined}
|
||||
className={clsx(css.list, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
// React portals bubble synthetic events through the REACT tree: without
|
||||
@@ -158,63 +255,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
// (open/toggle) after onSelect.
|
||||
onClick={(e) => { e.stopPropagation() }}
|
||||
>
|
||||
{items.map((entry) => {
|
||||
if (isSeparator(entry)) {
|
||||
return <div key={entry.id} className={css.separator} role="separator" />
|
||||
}
|
||||
if (isLabel(entry)) {
|
||||
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
|
||||
}
|
||||
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, entry.danger === true && css.danger)}
|
||||
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 className={css.viewport} role="presentation">
|
||||
{items.map(renderEntry)}
|
||||
</div>
|
||||
{footer !== undefined && footer.length > 0 && (
|
||||
<div className={css.footer} role="presentation">
|
||||
{footer.map(renderEntry)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 510;
|
||||
font-weight: 500; /* figma wt510, rendered 500 */
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Ongoing blue has no alias token (state-business-primary is the 500 step,
|
||||
* not this 450) — component-level var pinned to the static scale instead. */
|
||||
.dot,
|
||||
.ring {
|
||||
.matrix {
|
||||
--dsh-state-ongoing: var(--dsw-static-deepseek-450);
|
||||
}
|
||||
|
||||
@@ -42,24 +42,24 @@
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.ring {
|
||||
/* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
|
||||
* holds, no tweening — the retro feel), peaking when the chase hits it and
|
||||
* decaying over the next three cells. Phase offsets come from per-rect
|
||||
* animation-delay (index * -125ms) set inline by the component. */
|
||||
.matrix {
|
||||
flex: none;
|
||||
color: var(--dsh-state-ongoing);
|
||||
animation: dsh-state-dot-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.stopFrom {
|
||||
stop-color: currentColor;
|
||||
stop-opacity: 1;
|
||||
.cell {
|
||||
fill: currentColor;
|
||||
opacity: 0.15;
|
||||
animation: dsh-state-dot-chase 1s infinite;
|
||||
}
|
||||
|
||||
.stopTo {
|
||||
stop-color: currentColor;
|
||||
stop-opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes dsh-state-dot-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@keyframes dsh-state-dot-chase {
|
||||
0%, 12.4% { opacity: 1; }
|
||||
12.5%, 24.9% { opacity: 0.6; }
|
||||
25%, 37.4% { opacity: 0.35; }
|
||||
37.5%, 100% { opacity: 0.15; }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
|
||||
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
|
||||
// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a
|
||||
// linear gradient, spinning. Colors resolve through --dsw-* tokens only.
|
||||
// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
|
||||
// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
|
||||
|
||||
import { useId } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import css from './StateDot.module.css'
|
||||
|
||||
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
|
||||
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
|
||||
|
||||
/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
|
||||
const MATRIX_CELLS: readonly (readonly [number, number])[] = [
|
||||
[0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
|
||||
]
|
||||
|
||||
/**
|
||||
* Render a state dot.
|
||||
* @param props.state - which of the four states to show.
|
||||
@@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: {
|
||||
size?: number
|
||||
className?: string
|
||||
}) {
|
||||
const gradientId = useId()
|
||||
if (state === 'ongoing') {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(css.ring, className)}
|
||||
className={clsx(css.matrix, className)}
|
||||
data-state="ongoing"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 10 10"
|
||||
shapeRendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
{/* Gradient handles from the figma node: (0.1,0) -> (0.85,1). */}
|
||||
<linearGradient id={gradientId} x1="1" y1="0" x2="8.5" y2="10" gradientUnits="userSpaceOnUse">
|
||||
<stop className={css.stopFrom} offset="0" />
|
||||
<stop className={css.stopTo} offset="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="5" cy="5" r="4.5" fill="none" strokeWidth="1" stroke={`url(#${gradientId})`} />
|
||||
{MATRIX_CELLS.map(([x, y], index) => (
|
||||
<rect
|
||||
key={`${x}-${y}`}
|
||||
className={css.cell}
|
||||
x={x}
|
||||
y={y}
|
||||
width="2"
|
||||
height="2"
|
||||
/* Negative delay phases the chase so every cell animates from mount. */
|
||||
style={{ animationDelay: `${(index - MATRIX_CELLS.length) * 125}ms` }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
|
||||
{cloneElement(children, {
|
||||
ref: mergedRef,
|
||||
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
|
||||
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
|
||||
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
|
||||
@@ -271,14 +271,47 @@ describe('Menu', () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('portal mode positions from the opposite edges for align=end / side=top', () => {
|
||||
it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => {
|
||||
render(
|
||||
<Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(menu.style.right).not.toBe('')
|
||||
expect(menu.style.bottom).not.toBe('')
|
||||
expect(menu.style.left).toBe('')
|
||||
expect(menu.style.top).toBe('')
|
||||
expect(menu.style.left).not.toBe('')
|
||||
expect(menu.style.top).not.toBe('')
|
||||
expect(menu.style.right).toBe('')
|
||||
expect(menu.style.bottom).toBe('')
|
||||
})
|
||||
|
||||
it('renders footer rows in a pinned section below the items; they still select', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(
|
||||
<Menu
|
||||
open
|
||||
anchor={<span>trigger</span>}
|
||||
items={items}
|
||||
footer={[{ id: 'new', label: 'Create new' }]}
|
||||
onSelect={onSelect}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
const footerItem = screen.getByRole('menuitem', { name: 'Create new' })
|
||||
expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull()
|
||||
fireEvent.click(footerItem)
|
||||
expect(onSelect).toHaveBeenCalledWith('new')
|
||||
})
|
||||
|
||||
it('caps the list height for internal scrolling unless a submenu row is present', () => {
|
||||
const { rerender } = render(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
|
||||
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
|
||||
rerender(
|
||||
<Menu
|
||||
open
|
||||
anchor={<span>trigger</span>}
|
||||
items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]}
|
||||
onSelect={() => {}}
|
||||
onClose={() => {}}
|
||||
/>)
|
||||
expect(screen.getByRole('menu').className).not.toMatch(/scrollable/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,16 +14,17 @@ describe('StateDot', () => {
|
||||
expect(dot.getAttribute('aria-hidden')).toBe('true')
|
||||
})
|
||||
|
||||
it('solid states are spans; ongoing is an svg gradient ring', () => {
|
||||
it('solid states are spans; ongoing is an svg pixel matrix', () => {
|
||||
const { container, rerender } = render(<StateDot state="done" />)
|
||||
expect(container.firstElementChild?.tagName).toBe('SPAN')
|
||||
rerender(<StateDot state="ongoing" />)
|
||||
const ring = container.firstElementChild as SVGSVGElement
|
||||
expect(ring.tagName).toBe('svg')
|
||||
const circle = ring.querySelector('circle')
|
||||
expect(circle?.getAttribute('stroke-width')).toBe('1')
|
||||
expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/)
|
||||
expect(ring.querySelector('linearGradient')).not.toBeNull()
|
||||
const matrix = container.firstElementChild as SVGSVGElement
|
||||
expect(matrix.tagName).toBe('svg')
|
||||
const cells = matrix.querySelectorAll('rect')
|
||||
expect(cells).toHaveLength(8)
|
||||
// Chase phase: every cell carries its own negative animation delay.
|
||||
const delays = [...cells].map(cell => (cell).style.animationDelay)
|
||||
expect(new Set(delays).size).toBe(8)
|
||||
})
|
||||
|
||||
it('sizes via the size prop in both shapes', () => {
|
||||
|
||||
@@ -81,23 +81,20 @@ describe('Tooltip', () => {
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the bubble while either hover or focus is still active', () => {
|
||||
it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
|
||||
render(
|
||||
<Tooltip label="Sticky">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
const anchor = screen.getByText('anchor')
|
||||
// Focused AND hovered: leaving with the mouse must not drop the bubble.
|
||||
// Focused AND hovered: leaving with the mouse drops the bubble at once.
|
||||
fireEvent.focus(anchor)
|
||||
fireEvent.mouseEnter(anchor)
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
// Symmetric: blurring while still hovered keeps it, mouseleave ends it.
|
||||
// Re-entering shows it again; blurring while still hovered keeps it.
|
||||
fireEvent.mouseEnter(anchor)
|
||||
fireEvent.focus(anchor)
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.getByRole('tooltip')).toBeTruthy()
|
||||
fireEvent.mouseLeave(anchor)
|
||||
|
||||
@@ -72,6 +72,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.selector:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -80,18 +84,21 @@
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Tool Call mode cubes share an 8px gap. */
|
||||
/* Tool Call mode cubes share an 8px gap and wrap to one per row when the
|
||||
panel is too narrow. */
|
||||
.cubeRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset =
|
||||
* outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */
|
||||
/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the
|
||||
* 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10,
|
||||
* vertical = inner pad 8). */
|
||||
.modeCube {
|
||||
box-sizing: border-box;
|
||||
width: 418px;
|
||||
flex: 1 1 276px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -101,6 +108,11 @@
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modeCube:hover:not(.selected) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */
|
||||
/* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the
|
||||
Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -58,21 +59,22 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: var(--dsw-mask-blur);
|
||||
}
|
||||
|
||||
/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow
|
||||
(figma effects match --dsw-shadow-lv3 exactly). */
|
||||
/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects
|
||||
match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */
|
||||
.panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 1080px;
|
||||
height: 700px;
|
||||
width: 800px;
|
||||
height: 600px;
|
||||
max-width: calc(100vw - 48px);
|
||||
max-height: calc(100vh - 48px);
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,13 +79,19 @@
|
||||
|
||||
/* Brand group (figma I133:7632): the full wordmark rides the text ink
|
||||
(figma-flows ruling: main-screen instance is black; blue is brand
|
||||
emphasis only). */
|
||||
emphasis only). A button only in behavior (New Session shortcut): the
|
||||
pointer cursor is the sole affordance — no hover chrome on the mark. */
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
|
||||
@@ -61,10 +61,17 @@ export function SidebarRoot({
|
||||
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
|
||||
>
|
||||
<div className={css.logoRow}>
|
||||
{/* Expanded, the wordmark doubles as a New Session shortcut; the
|
||||
collapsed rail's logo is the expand toggle below instead. */}
|
||||
{wide && (
|
||||
<span className={clsx(css.brand, css.wide)}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.brand, css.wide)}
|
||||
aria-label="New session"
|
||||
onClick={() => { startSession() }}
|
||||
>
|
||||
<BrandWordmark />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Rail resting state is the whale mark; hovering swaps in the panel
|
||||
icon (the expand affordance, figma sidebar-hover flow). */}
|
||||
|
||||
@@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
|
||||
}
|
||||
|
||||
describe('SidebarRoot shell', () => {
|
||||
it('routes New Session and the column toggle', () => {
|
||||
it('routes New Session (capsule + wordmark) and the column toggle', () => {
|
||||
const b = mountShell()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
||||
expect(b.startSession).toHaveBeenCalledWith()
|
||||
// Expanded, both the wordmark and the capsule start a session.
|
||||
const starters = screen.getAllByRole('button', { name: 'New session' })
|
||||
expect(starters).toHaveLength(2)
|
||||
for (const button of starters) fireEvent.click(button)
|
||||
expect(b.startSession).toHaveBeenCalledTimes(2)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered
|
||||
* icon-over-label column, gap 4). */
|
||||
* icon-over-label column, gap 4); flexed down from the figma width so all
|
||||
* three sit on one row in the 800 panel, wrapping when narrower. */
|
||||
.themeCube {
|
||||
box-sizing: border-box;
|
||||
width: 276px;
|
||||
flex: 1 1 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -43,6 +45,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.themeCube:hover:not(.selected) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400
|
||||
* step has no alias-layer name). */
|
||||
.selected {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as
|
||||
font-weight: 500 in this UI — non-variable webfonts snap intermediate
|
||||
weights unpredictably across platforms. */
|
||||
body {
|
||||
--dsw-static-amber-100: rgb(254, 245, 231);
|
||||
--dsw-static-amber-400: rgb(247, 173, 49);
|
||||
@@ -20,7 +23,7 @@ body {
|
||||
--dsw-static-deepseek-300: rgb(183, 200, 254);
|
||||
--dsw-static-deepseek-400: rgb(103, 158, 254);
|
||||
--dsw-static-deepseek-450: rgb(86, 134, 254);
|
||||
--dsw-static-deepseek-500: rgb(57, 100, 254);
|
||||
--dsw-static-deepseek-500: rgb(65, 118, 230);
|
||||
--dsw-static-deepseek-50: rgb(237, 243, 254);
|
||||
--dsw-static-deepseek-600: rgb(72, 104, 178);
|
||||
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
|
||||
@@ -95,7 +98,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-static-deepseek-300: rgb(183, 200, 254);
|
||||
--dsw-static-deepseek-400: rgb(103, 158, 254);
|
||||
--dsw-static-deepseek-450: rgb(86, 134, 254);
|
||||
--dsw-static-deepseek-500: rgb(57, 100, 254);
|
||||
--dsw-static-deepseek-500: rgb(65, 118, 230);
|
||||
--dsw-static-deepseek-50: rgb(237, 243, 254);
|
||||
--dsw-static-deepseek-600: rgb(72, 104, 178);
|
||||
--dsw-static-deepseek-700-delete: rgb(47, 76, 143);
|
||||
@@ -302,7 +305,7 @@ body[data-ds-dark-theme] {
|
||||
--dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600);
|
||||
--dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600);
|
||||
--dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550);
|
||||
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-500);
|
||||
--dsw-alias-state-business-primary: var(--dsw-static-deepseek-400);
|
||||
--dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800);
|
||||
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
|
||||
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
reads the shell's class names): the two icon controls stack as 36x36
|
||||
circles matching the shell's rail rhythm. */
|
||||
.rail .sectionHeader {
|
||||
gap: 0;
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,7 @@ export function WorkspaceBrowser({
|
||||
// states; the menu anchors on this button).
|
||||
const [wsPickerOpen, setWsPickerOpen] = useState(false)
|
||||
const wsPlusRef = useRef<HTMLButtonElement>(null)
|
||||
const composingRef = useRef(false)
|
||||
|
||||
// Rail search = expand + land in the search box: the flag arms before the
|
||||
// expand request; once the shell flips wide the input mounts and takes focus.
|
||||
@@ -358,7 +359,6 @@ export function WorkspaceBrowser({
|
||||
className={css.iconButton}
|
||||
aria-label="Create workspace"
|
||||
onClick={() => {
|
||||
if (!wide) expandSidebar()
|
||||
setWsPickerOpen(v => !v)
|
||||
}}
|
||||
>
|
||||
@@ -372,6 +372,8 @@ export function WorkspaceBrowser({
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
createOnly
|
||||
side="right"
|
||||
onPick={(workspaceId) => {
|
||||
setWsPickerOpen(false)
|
||||
startSession(workspaceId)
|
||||
@@ -459,9 +461,12 @@ export function WorkspaceBrowser({
|
||||
aria-label="Workspace name"
|
||||
autoFocus
|
||||
disabled={renaming}
|
||||
onFocus={(e) => { e.target.select() }}
|
||||
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (e.key === 'Enter' && !composingRef.current) {
|
||||
e.preventDefault()
|
||||
confirmRename()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* slot registration.
|
||||
*/
|
||||
import type { RefObject } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -37,6 +37,12 @@ export interface WorkspaceCreateFlowProps {
|
||||
onPick: (workspaceId: WorkspaceId) => void
|
||||
/** Close the popover (outside click / Escape / post-pick). */
|
||||
onClose: () => void
|
||||
/** Only show create actions (open folder / create new), hide existing workspaces. */
|
||||
createOnly?: boolean
|
||||
/** Menu opening direction relative to the anchor. */
|
||||
side?: 'bottom' | 'top' | 'right'
|
||||
/** Currently active workspace (trailing check in the picker list). */
|
||||
selectedId?: WorkspaceId | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +58,9 @@ export function WorkspaceCreateFlow({
|
||||
pickDirectory,
|
||||
onPick,
|
||||
onClose,
|
||||
createOnly = false,
|
||||
side = 'bottom',
|
||||
selectedId,
|
||||
}: WorkspaceCreateFlowProps) {
|
||||
const workspaceSnapshot = useWorkspaces(state => state)
|
||||
const workspaces = workspaceSnapshot.items
|
||||
@@ -65,21 +74,26 @@ export function WorkspaceCreateFlow({
|
||||
const [modalError, setModalError] = useState<string | null>(null)
|
||||
const [pickingFolder, setPickingFolder] = useState(false)
|
||||
const [folderConflict, setFolderConflict] = useState(false)
|
||||
const composingRef = useRef(false)
|
||||
const normalizedWorkspaceName = workspaceName.trim()
|
||||
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
|
||||
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
|
||||
|
||||
const items: MenuEntry[] = [
|
||||
...workspaces.map(workspace => ({
|
||||
const createEntries: MenuEntry[] = [
|
||||
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
|
||||
]
|
||||
// With workspaces listed, the create actions pin below the scroll region
|
||||
// (divider + always visible); otherwise they ARE the menu.
|
||||
const pinCreate = !createOnly && workspaces.length > 0
|
||||
const items: MenuEntry[] = pinCreate
|
||||
? workspaces.map(workspace => ({
|
||||
id: workspace.workspaceId,
|
||||
label: workspace.title,
|
||||
icon: <IconFolderClose16 size={16} />,
|
||||
disabled: pickingFolder,
|
||||
})),
|
||||
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
|
||||
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
|
||||
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
|
||||
]
|
||||
}))
|
||||
: createEntries
|
||||
|
||||
const closeModal = (): void => {
|
||||
if (creating) return
|
||||
@@ -114,7 +128,7 @@ export function WorkspaceCreateFlow({
|
||||
}
|
||||
if (id === CREATE_NEW) {
|
||||
onClose()
|
||||
setWorkspaceName('workspace')
|
||||
setWorkspaceName('')
|
||||
setModalError(null)
|
||||
setModalKind('create')
|
||||
return
|
||||
@@ -149,8 +163,11 @@ export function WorkspaceCreateFlow({
|
||||
open={open}
|
||||
anchor={null}
|
||||
items={items}
|
||||
{...pinCreate ? { footer: createEntries } : {}}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
onClose={onClose}
|
||||
side={side}
|
||||
portal
|
||||
getAnchorRect={getAnchorRect}
|
||||
/>
|
||||
@@ -194,12 +211,15 @@ export function WorkspaceCreateFlow({
|
||||
<input
|
||||
className={css.modalInput}
|
||||
value={workspaceName}
|
||||
placeholder="Workspace name"
|
||||
aria-label="New workspace name"
|
||||
autoFocus
|
||||
disabled={creating}
|
||||
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === 'Enter' && !composingRef.current) {
|
||||
event.preventDefault()
|
||||
confirmCreate()
|
||||
}
|
||||
@@ -225,6 +245,7 @@ export function WorkspacePicker({
|
||||
open,
|
||||
anchorRef,
|
||||
useWorkspaces,
|
||||
selectedId,
|
||||
onPick,
|
||||
onClose,
|
||||
createWorkspace,
|
||||
@@ -237,6 +258,7 @@ export function WorkspacePicker({
|
||||
useWorkspaces={useWorkspaces}
|
||||
createWorkspace={createWorkspace}
|
||||
pickDirectory={pickDirectory}
|
||||
selectedId={selectedId}
|
||||
onPick={onPick}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
@@ -263,25 +263,21 @@ describe('WorkspaceBrowser', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
|
||||
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
|
||||
const expandSidebar = vi.fn()
|
||||
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
rerender(b, { wide: true })
|
||||
// The picker menu is open (anchored on the +); picking starts a session.
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
|
||||
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
// Wide toggle: open and close without expand requests.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
expect(expandSidebar).not.toHaveBeenCalled()
|
||||
// createOnly: existing workspaces are not listed, only the create actions.
|
||||
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
|
||||
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
|
||||
// Toggle: open and close in place.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(expandSidebar).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Escape closes the picker through its own onClose.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
expect(screen.getByRole('menu')).toBeTruthy()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -205,6 +205,8 @@ describe('WorkspacePicker', () => {
|
||||
it('reports non-Error creation failures', async () => {
|
||||
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
|
||||
chooseItem('Create a new workspace')
|
||||
// The name field starts empty (no prefill); a name is required to submit.
|
||||
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
|
||||
|
||||
@@ -15,6 +15,10 @@ body,
|
||||
|
||||
body {
|
||||
font-family: var(--dsw-font-family);
|
||||
/* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the
|
||||
Firefox macOS equivalent; other engines ignore both lines. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user