Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # docs/architecture.i18n.yaml # docs/core-data-structures/core.i18n.yaml # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/README.md # packages/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/tests/fake-api.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/fake-api.ts # packages/client/test-runtime/src/sessions.ts # packages/client/test-runtime/tests/runtime.spec.tsx # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/queue-dock.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/src/api/rpc-map.ts # packages/host/apiproxy/src/api/rpc.schema.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.schema.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/fetch/client.ts # packages/host/apiproxy/src/fetch/handler.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/client-handler.spec.ts # packages/host/apiproxy/tests/fetch-carrier.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts
This commit is contained in:
@@ -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: ad64511120e3d4308ab03bb45de21b7d577b4335
|
||||
README.zh.md: 69926a282dea3f13564fe97e75d0c7c937b86335
|
||||
README.md: c72183f3b323eda702ee9ebfb8722e3302b0f88d
|
||||
README.zh.md: 989c20a8fe6e50a6db021a5af610bfd044d7b210
|
||||
|
||||
@@ -36,9 +36,11 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
|
||||
- **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
|
||||
- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.
|
||||
|
||||
@@ -36,9 +36,11 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||
- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
|
||||
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。
|
||||
|
||||
@@ -34,11 +34,3 @@
|
||||
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.root:hover .actions,
|
||||
.root:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// 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.
|
||||
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
|
||||
// Finalized content (text) nodes append IconActions once streaming ends;
|
||||
// Think / tool-head-only nodes stay chrome-free.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -40,6 +41,11 @@ function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** True when the node has model-visible text content worth chrome under. */
|
||||
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
return (
|
||||
@@ -66,8 +72,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
// Footer only after the turn settles with a known event time; streaming omits it.
|
||||
const showActions = !streaming && time !== undefined
|
||||
// Footer only under settled content text; Think-only / streaming omit it.
|
||||
const showActions = !streaming && time !== undefined && hasContentText(blocks)
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.body}>
|
||||
|
||||
@@ -144,8 +144,11 @@
|
||||
}
|
||||
|
||||
:global([data-conversation-scroll]) .toBottomSlot {
|
||||
/* Clears the sticky composer stack (stats + docks + input card). */
|
||||
bottom: 168px;
|
||||
/* Clears the sticky composer stack (docks + input card + stats): the live
|
||||
height rides --dsh-composer-height (ConversationRoot's seat observer) so
|
||||
the control follows a growing textarea; the fallback covers the first
|
||||
paint before the observer fires. */
|
||||
bottom: calc(var(--dsh-composer-height, 152px) + 16px);
|
||||
}
|
||||
|
||||
.toBottom {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Shared message IconActions row (user + assistant). Parent modules own
|
||||
hover-reveal selectors and layout offsets via the composed className. */
|
||||
layout offsets via the composed className. Always visible when mounted. */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -25,14 +25,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface MessageIconActionsProps {
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Parent layout / hover-reveal class composed onto the actions row. */
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -29,14 +29,6 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.userRow:hover .actions,
|
||||
.userRow:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
736px message column axis. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
max-width: 736px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 24px 8px;
|
||||
padding: 4px 24px 0px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--dsw-alias-separator-primary);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import { Fragment, memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './StatsLine.module.css'
|
||||
@@ -10,7 +10,13 @@ import css from './StatsLine.module.css'
|
||||
interface UsageTotals {
|
||||
turns: number
|
||||
steps: number
|
||||
tokens: number
|
||||
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
|
||||
llmMs: number
|
||||
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
|
||||
toolMs: number
|
||||
/** Prompt-side tokens: inputTokens + cacheReadTokens. */
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheHitPct: number | null
|
||||
}
|
||||
|
||||
@@ -22,35 +28,72 @@ interface UsageLike {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold assistant nodes into display totals.
|
||||
* Fold assistant and tool-result nodes into display totals.
|
||||
* @param nodes - snapshot nodes.
|
||||
* @returns totals; cacheHitPct null until any cache accounting arrives.
|
||||
*/
|
||||
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
|
||||
const turns = new Set<number>()
|
||||
let steps = 0
|
||||
let tokens = 0
|
||||
let llmMs = 0
|
||||
let toolMs = 0
|
||||
let input = 0
|
||||
let output = 0
|
||||
let cacheRead = 0
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-result') {
|
||||
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
|
||||
continue
|
||||
}
|
||||
if (node.kind !== 'assistant') continue
|
||||
turns.add(node.turn)
|
||||
steps += 1
|
||||
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
|
||||
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
|
||||
}
|
||||
const usage = node.usage as UsageLike | undefined
|
||||
if (usage === undefined) continue
|
||||
input += usage.inputTokens ?? 0
|
||||
output += usage.outputTokens ?? 0
|
||||
cacheRead += usage.cacheReadTokens ?? 0
|
||||
tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
|
||||
}
|
||||
const denom = input + cacheRead
|
||||
return {
|
||||
turns: turns.size,
|
||||
steps,
|
||||
tokens,
|
||||
llmMs,
|
||||
toolMs,
|
||||
inputTokens: input + cacheRead,
|
||||
outputTokens: output,
|
||||
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact token count: 517 / 12.2K / 517K / 1.2M (one decimal under three digits).
|
||||
* @param n - token count.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatTokens(n: number): string {
|
||||
const scaled = (v: number): string =>
|
||||
v >= 100 ? String(Math.round(v)) : String(Math.round(v * 10) / 10)
|
||||
if (n < 1_000) return String(n)
|
||||
if (n < 1_000_000) return `${scaled(n / 1_000)}K`
|
||||
return `${scaled(n / 1_000_000)}M`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact duration: 45.2s under a minute, 2m42s from there on.
|
||||
* @param ms - duration in milliseconds.
|
||||
* @returns display string.
|
||||
*/
|
||||
export function formatDuration(ms: number): string {
|
||||
const s = ms / 1_000
|
||||
if (s < 60) return `${Math.round(s * 10) / 10}s`
|
||||
const whole = Math.round(s)
|
||||
return `${Math.floor(whole / 60)}m${whole % 60}s`
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector (dock registration or unit mount). */
|
||||
export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationSnapshot> }
|
||||
|
||||
@@ -58,10 +101,22 @@ export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const stats = useMemo(() => deriveStats(nodes), [nodes])
|
||||
if (stats.steps === 0) return null
|
||||
const parts: string[] = []
|
||||
if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
|
||||
parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
|
||||
parts.push(`${stats.turns} turns`)
|
||||
parts.push(`${stats.steps} steps`)
|
||||
return <div className={css.root}>{parts.join(' · ')}</div>
|
||||
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
|
||||
const groups: string[] = [`${stats.turns} turns · ${stats.steps} steps`]
|
||||
const durations: string[] = []
|
||||
if (stats.llmMs > 0) durations.push(`LLM ${formatDuration(stats.llmMs)}`)
|
||||
if (stats.toolMs > 0) durations.push(`Tool call ${formatDuration(stats.toolMs)}`)
|
||||
if (durations.length > 0) groups.push(durations.join(' · '))
|
||||
if (stats.cacheHitPct !== null) groups.push(`Cache hit ${stats.cacheHitPct}%`)
|
||||
groups.push(`Input ${formatTokens(stats.inputTokens)} tok · Output ${formatTokens(stats.outputTokens)} tok`)
|
||||
return (
|
||||
<div className={css.root}>
|
||||
{groups.map((group, i) => (
|
||||
<Fragment key={group}>
|
||||
{i > 0 && <span className={css.sep} aria-hidden>|</span>}
|
||||
<span>{group}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
13
packages/client/ui-conversation/src/client/contract/queue.ts
Normal file
13
packages/client/ui-conversation/src/client/contract/queue.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** Queue contracts derived from the runtime session face and snapshot. */
|
||||
import type {
|
||||
ConversationSnapshot, SessionFace,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One address accepted by the runtime session's queue mutation verb. */
|
||||
export type QueueItemId = Parameters<SessionFace['updateQueue']>[0]
|
||||
|
||||
/** One mutation accepted by the runtime session's queue mutation verb. */
|
||||
export type QueueAction = Parameters<SessionFace['updateQueue']>[1]
|
||||
|
||||
/** One row projected by the runtime session's authoritative queue snapshot. */
|
||||
export type QueueRow = ConversationSnapshot['queue'][number]
|
||||
@@ -76,7 +76,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* design §6 MIX evidence: entries coexist in fixed order).
|
||||
*/
|
||||
'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** The composer top-edge band (stats line family). */
|
||||
/** The band under the composer card (stats line family), rendered inside the bar's width column via the `footer` owner prop. */
|
||||
'conversation.composer.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
/** Tool-row left region inside the input card (existing chrome stays in place beside entries). */
|
||||
'conversation.input.left': { kind: 'list'; scope: 'session'; owner: InputZone }
|
||||
@@ -264,6 +264,8 @@ export interface ComposerBarOwnerProps {
|
||||
leftItems?: ReactNode
|
||||
/** input.right slot entries (tool row, before the primary button). */
|
||||
rightItems?: ReactNode
|
||||
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
|
||||
footer?: ReactNode
|
||||
onAdd?: () => void
|
||||
addLabel?: string
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { QueueRow } from '../contract/queue.ts'
|
||||
|
||||
/** Browser-runtime identity of one unsent image draft. */
|
||||
export type DraftAttachmentId = Branded<'DraftAttachmentId'>
|
||||
@@ -115,12 +116,8 @@ export interface ComposerKeyboard {
|
||||
dismissPopup(): void
|
||||
}
|
||||
|
||||
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
|
||||
export interface QueuedMessage {
|
||||
/** Stable row key: the enqueueing prompt's rpcId. */
|
||||
readonly key: string
|
||||
readonly preview: string
|
||||
}
|
||||
/** One independently addressable row projected from the transient queue snapshot. */
|
||||
export type QueuedMessage = QueueRow
|
||||
|
||||
/** Guard union of the scoped consume-token event, checked by the machine. */
|
||||
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
|
||||
|
||||
@@ -1,30 +1,114 @@
|
||||
/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */
|
||||
/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */
|
||||
|
||||
.dock {
|
||||
margin: 6px 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--dsw-alias-separator-primary);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
|
||||
the later composer sibling paints its surface and shadow over this edge. */
|
||||
margin: 0 auto -10px;
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
.panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
padding-top: 2px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
.panel::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-bottom: none;
|
||||
border-radius: inherit;
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 4px 0 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 4px 5px 4px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.preview,
|
||||
.editor {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-family: Inter, var(--dsw-font-family);
|
||||
}
|
||||
|
||||
.preview {
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.editor {
|
||||
box-sizing: border-box;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.editor:focus {
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-label-tertiary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,185 @@
|
||||
// Read-only queue dock entry (design v4 queue cut 1): renders the session's
|
||||
// inbox mirror (session/queued frames + connect baseline) as one stacked
|
||||
// strip above the input. No per-row actions — the host inbox has no
|
||||
// addressable entries yet (queue cut 2 ledger).
|
||||
// Queue dock entry: renders the authoritative transient inbox snapshot and
|
||||
// addresses per-row mutations through the session-scoped conversation face.
|
||||
//
|
||||
// The 'conversation.input.dock' SlotMap declaration lives in
|
||||
// ../contract/slots.ts beside the other input-region slots.
|
||||
import type { Context } from 'cordis'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import css from './QueueDock.module.css'
|
||||
|
||||
/** Queue operations injected by the session-scoped registration. */
|
||||
export interface QueueDockInjected {
|
||||
updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
|
||||
notify: (level: 'info' | 'error', text: string) => void
|
||||
}
|
||||
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
|
||||
export type QueueDockProps = PropsRuntime<'conversation.input.dock'>
|
||||
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
|
||||
|
||||
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
|
||||
export function QueueDock({ useSession }: QueueDockProps) {
|
||||
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
const queue = useSession(s => s.queue)
|
||||
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
|
||||
const [busy, setBusy] = useState<QueueItemId | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
|
||||
}, [editing, queue])
|
||||
|
||||
if (queue.length === 0) return null
|
||||
|
||||
const applyAction = async (
|
||||
itemId: QueueItemId,
|
||||
action: QueueAction,
|
||||
failure: string,
|
||||
): Promise<boolean> => {
|
||||
setBusy(itemId)
|
||||
try {
|
||||
await updateQueue(itemId, action)
|
||||
return true
|
||||
} catch {
|
||||
notify('error', failure)
|
||||
return false
|
||||
} finally {
|
||||
setBusy(current => current === itemId ? null : current)
|
||||
}
|
||||
}
|
||||
|
||||
const saveEdit = async (): Promise<void> => {
|
||||
if (editing === null || editing.text.trim() === '') return
|
||||
if (await applyAction(
|
||||
editing.id,
|
||||
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
|
||||
'编辑失败:这条消息可能已经开始发送。',
|
||||
)) setEditing(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.dock}>
|
||||
<div className={css.title}>已排队 {queue.length} 条</div>
|
||||
<ul className={css.list}>
|
||||
{queue.map(row => (
|
||||
<li key={row.key} className={css.row}>{row.preview}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={css.panel}>
|
||||
<ul className={css.list}>
|
||||
{queue.map(row => (
|
||||
<li key={row.id} className={css.row}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<input
|
||||
autoFocus
|
||||
className={css.editor}
|
||||
aria-label="编辑排队消息"
|
||||
value={editing.text}
|
||||
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setEditing(null)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
void saveEdit()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: <span className={css.preview}>{row.preview}</span>}
|
||||
<div className={css.actions}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="保存排队消息"
|
||||
title="保存排队消息"
|
||||
disabled={busy !== null || editing.text.trim() === ''}
|
||||
onClick={() => { void saveEdit() }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="取消编辑"
|
||||
title="取消编辑"
|
||||
disabled={busy !== null}
|
||||
onClick={() => { setEditing(null) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="编辑排队消息"
|
||||
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
|
||||
disabled={busy !== null || row.text === null}
|
||||
onClick={() => {
|
||||
if (row.text !== null) setEditing({ id: row.id, text: row.text })
|
||||
}}
|
||||
>
|
||||
<IconEditOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="删除排队消息"
|
||||
title="删除排队消息"
|
||||
disabled={busy !== null}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'remove' },
|
||||
'删除失败:这条消息可能已经开始发送。',
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The dock entry as a plain registrant plugin (bash posture).
|
||||
* `inject: ['conversation']` is the ordering seam: the conversation service
|
||||
* mounts after ui-conversation's slot registrations, so the
|
||||
* 'conversation.input.dock' declaration is on the ledger by then.
|
||||
* The dock entry as a plain registrant plugin. The conversation service is the
|
||||
* ordering and action seam; session scopes provide the exact queue owner.
|
||||
*/
|
||||
export const queueDockEntry = {
|
||||
name: 'conversation-queue-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
inject: ['slots', 'conversation', 'sessions'],
|
||||
/**
|
||||
* Register the queue strip into the input dock (list entry, order 0).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 0 }, QueueDock)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'queue',
|
||||
order: 0,
|
||||
inject: (sessionId: SessionId): QueueDockInjected => {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)
|
||||
const conversation = actx.get('conversation')
|
||||
if (conversation === undefined) throw new Error('queue dock: conversation service unavailable')
|
||||
return {
|
||||
updateQueue: (itemId, action) => conversation.updateQueue(itemId, action),
|
||||
notify: (level, text) => { conversation.input.for(actx).notify(level, text) },
|
||||
}
|
||||
},
|
||||
}, QueueDock)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import type { QueuedMessage } from '../input/contract.ts'
|
||||
/**
|
||||
* Project a session's queue rows as a bare observable (subscribe/getSnapshot).
|
||||
* The wiring layer (T5) overlays this onto InputState.queue; the runtime
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally the
|
||||
* same frozen shape ({key, preview}).
|
||||
* QueuedMessage and the input-contract QueuedMessage are structurally
|
||||
* identical.
|
||||
* @param session - the resident session face.
|
||||
* @returns the queue read face (snapshot reference stable while the queue is unchanged).
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Context } from 'cordis'
|
||||
import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ComposerAttachment } from './contract/slots.ts'
|
||||
import type { QueueAction, QueueItemId } from './contract/queue.ts'
|
||||
import type { DraftAttachmentId, InputService } from './input/contract.ts'
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,13 @@ export interface IConversation {
|
||||
* @returns completion; business failures reject (and land in promptError).
|
||||
*/
|
||||
send(text: string, mode: 'queue' | 'steer'): Promise<void>
|
||||
/**
|
||||
* Apply one operation to a pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - edit or remove operation.
|
||||
* @returns completion; business failures reject.
|
||||
*/
|
||||
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
|
||||
/**
|
||||
* Cancel the scoped session's in-flight turn.
|
||||
* @returns completion; failures reject as in send.
|
||||
@@ -237,6 +245,15 @@ export class ConversationService extends Service implements IConversation {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one operation to a pending queue occurrence. */
|
||||
async updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void> {
|
||||
const session = this.scopedSession('updateQueue')
|
||||
const result = await session.updateQueue(itemId, action)
|
||||
if (!result.ok) {
|
||||
throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */
|
||||
async cancel(): Promise<void> {
|
||||
const session = this.scopedSession('cancel')
|
||||
|
||||
@@ -127,10 +127,14 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Composer stack: dock strips above the input card (design §6 MIX order). */
|
||||
/* Composer stack: dock strips above the input card (design §6 MIX order).
|
||||
The stack owns the vertical rhythm: one gap here, entries carry no outer
|
||||
margins — an entry that renders null costs nothing, so spacing stays
|
||||
correct for any dock combination. */
|
||||
.composerStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
@@ -170,7 +174,17 @@
|
||||
/* Above markdown CodeBlock sticky banners (z-index 6) so the footer never
|
||||
paints under a sticking code header while scrolling. */
|
||||
z-index: 7;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
/* Input mask (figma 1205:27463): transcript fades out under a FIXED 36px
|
||||
band at the seat's top (the figma 24% of the resting ~150px composer),
|
||||
solid below — px stops, not %, so a growing draft only widens the solid
|
||||
region and the fade band never stretches. The 0px stop is bg-base at
|
||||
zero alpha (not white, which the figma export hardcodes) so both themes
|
||||
fade from their own base. */
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--dsw-alias-bg-base) 0%, transparent) 0px,
|
||||
var(--dsw-alias-bg-base) 36px
|
||||
);
|
||||
}
|
||||
|
||||
/* Hero phase: the composer stack (hero chrome + workspace row + card) is
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// chain stay mounted across no-session/session transitions. Only the inert
|
||||
// input body swaps for the strict session InputBar.
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
|
||||
@@ -29,6 +29,23 @@ export function ConversationRoot({
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<WorkspaceId | undefined>()
|
||||
const pickerAnchor = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Publishes the seat's live height as --dsh-composer-height on the scroll
|
||||
// body so floating controls (ChatView back-to-bottom) clear the composer as
|
||||
// it grows. Callback ref, not an effect: the seat remounts when the tree
|
||||
// moves between the no-session and session paths. Stable identity so React
|
||||
// reattaches only on those remounts, not on every render.
|
||||
const seatObserver = useRef<ResizeObserver | null>(null)
|
||||
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
|
||||
seatObserver.current?.disconnect()
|
||||
seatObserver.current = null
|
||||
const scroller = seat?.parentElement ?? null
|
||||
if (seat === null || scroller === null) return
|
||||
seatObserver.current = new ResizeObserver(() => {
|
||||
scroller.style.setProperty('--dsh-composer-height', `${seat.offsetHeight}px`)
|
||||
})
|
||||
seatObserver.current.observe(seat)
|
||||
}, [])
|
||||
|
||||
const sessionWorkspace = sessionId === undefined
|
||||
? undefined
|
||||
: workspaces.items.find(workspace => workspace.sessionIds.includes(sessionId))
|
||||
@@ -106,6 +123,9 @@ export function ConversationRoot({
|
||||
overlay: renderSlot('conversation.input.overlay', {}),
|
||||
leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),
|
||||
rightItems: zone === undefined ? null : renderSlot('conversation.input.right', zone),
|
||||
// Stats band under the card, inside the bar's width column so both
|
||||
// share one constraint (composer.dock = stats-line family).
|
||||
footer: !hero && zone !== undefined ? renderSlot('conversation.composer.dock', zone) : null,
|
||||
})
|
||||
|
||||
const composerBar = (
|
||||
@@ -113,9 +133,6 @@ export function ConversationRoot({
|
||||
{hero && <HeroGlow className={css.heroGlow} />}
|
||||
{hero && <HeroShell />}
|
||||
{hero && heroWorkspaceRow}
|
||||
{/* Stats band above the input-dock strips so the prior ChatView footer
|
||||
order (stats → todo/queue → card) is preserved under the sticky stack. */}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.composer.dock', zone)}
|
||||
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
|
||||
{inputBar}
|
||||
</div>
|
||||
@@ -133,7 +150,7 @@ export function ConversationRoot({
|
||||
// on the fallback alone would leave Question/Approval panels at the content
|
||||
// end off-screen when the user is not pinned to the floor.
|
||||
const composerSeat = (
|
||||
<div className={css.composerSeat} data-composer-seat="">
|
||||
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
|
||||
{composer}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||
the chat scroller. Top 6 is the gap under the dock todo strip (12px todo
|
||||
margin + 6px here); error/status strips still carry their own margin. */
|
||||
padding: 6px 32px 12px;
|
||||
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
|
||||
the chat scroller. No top pad: the composer stack's gap owns the space
|
||||
above; error/status strips still carry their own margin. */
|
||||
padding: 0 32px 8px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -279,12 +279,16 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
/* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */
|
||||
min-height: 52px;
|
||||
max-height: 336px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Hero (centered empty-state) keeps the 2-line floor (figma min-h 52 = ~2 × 24
|
||||
line + 4pt); the docked composer collapses to the content height. */
|
||||
.hero .mirror {
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
|
||||
(figma Input_Bottom chrome). */
|
||||
.row {
|
||||
|
||||
@@ -32,7 +32,7 @@ export type InputBarProps = ComposerBarProps
|
||||
export function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages,
|
||||
stop, command, translateHint, renderSlot, useNotices, useLexicon, useProjection,
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
|
||||
variant, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment',
|
||||
}: InputBarProps) {
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
@@ -516,6 +516,7 @@ export function InputBar({
|
||||
</div>
|
||||
</div>
|
||||
{preview !== null && <ImageLightbox src={preview.previewUrl} alt={preview.file.name || '原图'} onClose={closePreview} />}
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
|
||||
tip surface, 14px radius, status icons + secondary item labels. Column is
|
||||
calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */
|
||||
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
|
||||
stack owns the gap. */
|
||||
|
||||
.root {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
width: calc(100% - 88px);
|
||||
max-width: 776px;
|
||||
max-width: 752px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
@@ -20,11 +21,13 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Compact scale (GoalBar reference): collapsed header totals the goal
|
||||
strip's 38px (8+8 pad + 20 line + 2 border). */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -41,8 +44,8 @@
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -29,9 +29,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const TODOS: TodoItem[] = [
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('small branch tails', () => {
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
|
||||
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -199,6 +199,17 @@ describe('small branch tails', () => {
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
settled.unmount()
|
||||
|
||||
const thinkOnly = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
/>,
|
||||
)
|
||||
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(thinkOnly.queryByText('14:24')).toBeNull()
|
||||
thinkOnly.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
@@ -216,6 +227,6 @@ describe('small branch tails', () => {
|
||||
const view = render(
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as StatsLineProps['useSession']} />,
|
||||
)
|
||||
expect(view.getByText('10 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,9 +23,20 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const PROGRAM = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\nreturn listing'
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { StatsLine, deriveStats, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { StatsLine, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
|
||||
import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -51,7 +51,7 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
|
||||
}
|
||||
|
||||
describe('deriveStats', () => {
|
||||
it('folds turns/steps/tokens and cache hit percentage', () => {
|
||||
it('folds turns/steps/token split and cache hit percentage', () => {
|
||||
const stats = deriveStats([
|
||||
assistant(1, 1, { inputTokens: 100, outputTokens: 50, cacheReadTokens: 900 }),
|
||||
assistant(2, 1, { inputTokens: 100, outputTokens: 50 }),
|
||||
@@ -59,19 +59,53 @@ describe('deriveStats', () => {
|
||||
])
|
||||
expect(stats.turns).toBe(2)
|
||||
expect(stats.steps).toBe(3)
|
||||
expect(stats.tokens).toBe(1200)
|
||||
expect(stats.inputTokens).toBe(1100)
|
||||
expect(stats.outputTokens).toBe(100)
|
||||
expect(stats.cacheHitPct).toBe(82)
|
||||
})
|
||||
|
||||
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
|
||||
it('cache hit stays null with no cache accounting; out-of-window tool results ignored', () => {
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const stats = deriveStats([tool, assistant(1, 1)])
|
||||
expect(stats.steps).toBe(1)
|
||||
expect(stats.toolMs).toBe(0)
|
||||
expect(stats.cacheHitPct).toBeNull()
|
||||
})
|
||||
|
||||
it('sums LLM wall time from assistant timing and tool wall time from call/result pairs', () => {
|
||||
const timed: AssistantMessageNode = {
|
||||
...assistant(1, 1),
|
||||
timing: { stepStartTime: 1_000, firstTokenTime: 1_200, completedTime: 3_500 },
|
||||
}
|
||||
const untimed: AssistantMessageNode = {
|
||||
...assistant(2, 1),
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: 9_000 },
|
||||
}
|
||||
const tool: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 5, time: 7_000, callId: 'c', call: null, callTime: 4_000, content: [],
|
||||
isError: false, callView: null, resultView: null,
|
||||
}
|
||||
const stats = deriveStats([timed, untimed, tool])
|
||||
expect(stats.llmMs).toBe(2_500)
|
||||
expect(stats.toolMs).toBe(3_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatters', () => {
|
||||
it('formats token counts compactly', () => {
|
||||
expect(formatTokens(517)).toBe('517')
|
||||
expect(formatTokens(12_240)).toBe('12.2K')
|
||||
expect(formatTokens(517_000)).toBe('517K')
|
||||
expect(formatTokens(1_230_000)).toBe('1.2M')
|
||||
})
|
||||
|
||||
it('formats durations under and over a minute', () => {
|
||||
expect(formatDuration(45_230)).toBe('45.2s')
|
||||
expect(formatDuration(162_000)).toBe('2m42s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatsLine', () => {
|
||||
@@ -79,12 +113,13 @@ describe('StatsLine', () => {
|
||||
return { useSession: bindSnapshotSelector(source) }
|
||||
}
|
||||
|
||||
it('renders the joined stats row and hides with zero steps', () => {
|
||||
it('renders the grouped stats row and hides with zero steps', () => {
|
||||
const { source } = makeSource({
|
||||
nodes: [assistant(1, 1, { inputTokens: 10, outputTokens: 5, cacheReadTokens: 90 })],
|
||||
})
|
||||
const view = render(<StatsLine {...props(source)} />)
|
||||
expect(view.getByText('cache hit 90% · 105 tokens · 1 turns · 1 steps')).toBeTruthy()
|
||||
// No timing on the fixture: the duration group drops out whole.
|
||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Cache hit 90%|Input 100 tok · Output 5 tok')
|
||||
const empty = makeSource()
|
||||
const emptyView = render(<StatsLine {...props(empty.source)} />)
|
||||
expect(emptyView.container.textContent).toBe('')
|
||||
|
||||
@@ -21,10 +21,21 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
// The chat store persists under its declared key; clear between cases.
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('render branch tails', () => {
|
||||
const view = render(
|
||||
<StatsLine useSession={bindSnapshotSelector(source) as unknown as UseSession<ConversationSnapshot>} />,
|
||||
)
|
||||
expect(view.getByText('cache hit 0% · 15 tokens · 2 turns · 3 steps')).toBeTruthy()
|
||||
expect(view.container.textContent).toBe('2 turns · 3 steps|Cache hit 0%|Input 9 tok · Output 6 tok')
|
||||
})
|
||||
|
||||
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* QueueDock rendering (web input-triggers queue cut 1): empty queue renders
|
||||
* nothing, rows render one preview line each keyed by rpcId, and the strip
|
||||
* follows queue changes through the useSession selector.
|
||||
* QueueDock rendering and operations: authoritative rows, inline editing,
|
||||
* removal, failure notices, and live retirement.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
ConversationSnapshot, QueuedMessage, SessionId, SessionListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { QueueItemId } from '../src/client/contract/queue.ts'
|
||||
import type { InputState } from '../src/client/input/contract.ts'
|
||||
import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx'
|
||||
import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
const iid = (id: string): QueueItemId => id as QueueItemId
|
||||
|
||||
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
|
||||
return { id: iid(id), preview, text }
|
||||
}
|
||||
|
||||
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
return {
|
||||
@@ -24,23 +31,23 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */
|
||||
/** Minimal live source backing the useSession stub. */
|
||||
function liveSession(initial: ConversationSnapshot) {
|
||||
let snapshot = initial
|
||||
const listeners = new Set<() => void>()
|
||||
const useSession: SnapshotSelectorHook<ConversationSnapshot> = sel =>
|
||||
const useSession: SnapshotSelectorHook<ConversationSnapshot> = selector =>
|
||||
useSyncExternalStore(
|
||||
(fn) => {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
(listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
() => sel(snapshot),
|
||||
() => selector(snapshot),
|
||||
)
|
||||
return {
|
||||
useSession,
|
||||
push(next: ConversationSnapshot): void {
|
||||
snapshot = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -48,7 +55,7 @@ function liveSession(initial: ConversationSnapshot) {
|
||||
/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */
|
||||
const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
|
||||
|
||||
function kitFor(snapshot: ConversationSnapshot) {
|
||||
function kitFor(snapshot: ConversationSnapshot, injected: Partial<QueueDockInjected> = {}) {
|
||||
return {
|
||||
sessionId: SID,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
@@ -58,6 +65,9 @@ function kitFor(snapshot: ConversationSnapshot) {
|
||||
inputActions: { setDraft: () => {}, submit: () => {} } as never,
|
||||
session: snapshot,
|
||||
input: INPUT_STATE,
|
||||
updateQueue: vi.fn(() => Promise.resolve()),
|
||||
notify: vi.fn(),
|
||||
...injected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,20 +79,118 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders one preview row per queued message with the count strip', () => {
|
||||
it('renders active actions and disables editing for mixed-content rows', () => {
|
||||
const snap = snapshotWith([
|
||||
{ key: 'p-1', preview: '第一条排队消息' },
|
||||
{ key: 'p-2', preview: 'second queued line' },
|
||||
row('i-1', '第一条排队消息'),
|
||||
row('i-2', null, 'image [image]'),
|
||||
])
|
||||
const source = liveSession(snap)
|
||||
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
expect(container.textContent).toContain('已排队 2 条')
|
||||
const rows = [...container.querySelectorAll('li')]
|
||||
expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line'])
|
||||
expect([...container.querySelectorAll('li')].map(item => item.textContent))
|
||||
.toEqual(['第一条排队消息', 'image [image]'])
|
||||
expect(container.querySelectorAll('button')).toHaveLength(4)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
|
||||
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title'))
|
||||
.toBe('包含非文本内容,暂不支持编辑')
|
||||
})
|
||||
|
||||
it('follows queue changes: retirement empties the strip back to null', () => {
|
||||
const snap = snapshotWith([{ key: 'p-1', preview: '在场' }])
|
||||
it('edits text inline with save and cancel controls, then saves with the same item identity', async () => {
|
||||
const snap = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getByLabelText, queryByLabelText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
const editor = getByLabelText('编辑排队消息') as HTMLInputElement
|
||||
expect(getByLabelText('保存排队消息')).toBeTruthy()
|
||||
expect(getByLabelText('取消编辑')).toBeTruthy()
|
||||
expect(queryByLabelText('删除排队消息')).toBeNull()
|
||||
fireEvent.change(editor, { target: { value: 'after' } })
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), {
|
||||
kind: 'edit',
|
||||
content: [{ type: 'text', text: 'after' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('cancels an edit by button or Escape without mutating the queue', () => {
|
||||
const snap = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getByLabelText, getByText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } })
|
||||
fireEvent.click(getByLabelText('取消编辑'))
|
||||
expect(getByText('before')).toBeTruthy()
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' })
|
||||
expect(getByText('before')).toBeTruthy()
|
||||
expect(updateQueue).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps editing during IME composition and disables a blank save', () => {
|
||||
const snap = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getByLabelText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('编辑排队消息'))
|
||||
const editor = getByLabelText('编辑排队消息')
|
||||
fireEvent.change(editor, { target: { value: ' ' } })
|
||||
expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true)
|
||||
fireEvent.change(editor, { target: { value: '输入中' } })
|
||||
fireEvent.keyDown(editor, { key: 'Enter', isComposing: true })
|
||||
expect(updateQueue).not.toHaveBeenCalled()
|
||||
expect(getByLabelText('编辑排队消息')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('removes the addressed row', async () => {
|
||||
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getAllByLabelText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the row and surfaces a notice when an operation loses the claim race', async () => {
|
||||
const snap = snapshotWith([row('i-race', 'pending')])
|
||||
const source = liveSession(snap)
|
||||
const notify = vi.fn()
|
||||
const updateQueue = vi.fn(() => Promise.reject(new Error('not found')))
|
||||
const { getByLabelText, getByText } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByLabelText('删除排队消息'))
|
||||
await waitFor(() => {
|
||||
expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。')
|
||||
})
|
||||
expect(getByText('pending')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('follows authoritative retirement back to null', () => {
|
||||
const snap = snapshotWith([row('i-1', '在场')])
|
||||
const source = liveSession(snap)
|
||||
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
expect(container.textContent).toContain('在场')
|
||||
@@ -90,11 +198,9 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => {
|
||||
// Registration itself runs under T5's slot declaration; here we pin the
|
||||
// frozen registration surface so the wiring layer can mount it verbatim.
|
||||
it('ships the session-scoped registrant plugin shape', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
expect(typeof queueDockEntry.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,11 +14,12 @@ import { ConversationService } from '../src/client/service.ts'
|
||||
async function bench(readAttachment?: SessionFace['readAttachment']) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
const loadOlder = vi.fn(() => Promise.resolve())
|
||||
await runtime.sessions.add({
|
||||
id: 's1',
|
||||
session: { prompt, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
|
||||
session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) },
|
||||
})
|
||||
// config.input is required (the apply shares its hub with the inject
|
||||
// factories); the bench passes its own instance explicitly.
|
||||
@@ -27,16 +28,18 @@ async function bench(readAttachment?: SessionFace['readAttachment']) {
|
||||
await fiber.await()
|
||||
const root = runtime.ctx.get('conversation') as ConversationService
|
||||
const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService
|
||||
return { runtime, fiber, hub, root, scoped, prompt, cancel, loadOlder }
|
||||
return { runtime, fiber, hub, root, scoped, prompt, updateQueue, cancel, loadOlder }
|
||||
}
|
||||
|
||||
describe('ConversationService', () => {
|
||||
it('routes operations through the public Session binding', async () => {
|
||||
const b = await bench()
|
||||
await b.scoped.send('hello', 'steer')
|
||||
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
|
||||
await b.scoped.cancel()
|
||||
await b.scoped.loadOlder()
|
||||
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
|
||||
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
expect(b.loadOlder).toHaveBeenCalledOnce()
|
||||
await b.runtime.dispose()
|
||||
|
||||
@@ -28,8 +28,21 @@ function fakeWiring() {
|
||||
return { wiring: shell, sink, shell }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
})
|
||||
|
||||
const sid = (id: string) => id as SessionId
|
||||
const wid = (id: string) => id as WorkspaceId
|
||||
|
||||
Reference in New Issue
Block a user