Merge master into fix/turn-actions
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: 77f73408953003899b0b90661b01a2d0411f01cb
|
||||
README.zh.md: 1ce9787df01719b81115448f914a74aad9a99b16
|
||||
README.md: bfb56dc52406e1377cd84c87866a644ade10293a
|
||||
README.zh.md: e09bdcc4bebd8176a3061b221e07ae73a7a8941c
|
||||
|
||||
@@ -20,6 +20,8 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to
|
||||
|
||||
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>` parsed from its args, 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). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing 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.
|
||||
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions.
|
||||
|
||||
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
|
||||
|
||||
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `command.hint` locale namespace this package registers and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
|
||||
@@ -38,7 +40,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **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 content IconActions row (copy / branch / clock) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free; 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 the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected.
|
||||
- **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.
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
|
||||
@@ -36,9 +38,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **统计行的耗时只覆盖窗口内消息流**:LLM 与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **统计行的耗时只覆盖窗口内消息流**:LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个 turn 最后一条带 text 的 assistant 消息下;turn 中间叙述与纯 Think 节点不带 chrome;分支仍是 chrome stub。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -262,6 +262,13 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
},
|
||||
loadOlder: () => { void scoped.loadOlder() },
|
||||
forkAt: (seq) => {
|
||||
sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
|
||||
.then((childId) => { sessions.open(childId) })
|
||||
.catch(() => {
|
||||
// Fork or child-rename failure keeps the source view untouched.
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}, ChatView)
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface AssistantMarkdownProps {
|
||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
||||
* the parent withholds chrome (mid-turn content assistants). */
|
||||
time?: number | undefined
|
||||
/** Event sequence used as the fork boundary; omitted while streaming. */
|
||||
seq?: number | undefined
|
||||
/** Fork the session through the turn containing this finalized message. */
|
||||
onFork?: ((seq: number) => void) | undefined
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
@@ -62,7 +66,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time,
|
||||
blocks, streaming, interrupted, time, seq, onFork,
|
||||
}: AssistantMarkdownProps) {
|
||||
const last = blocks.length - 1
|
||||
// Tool-call heads render as tool rows in the chat view's grouping pass, so
|
||||
@@ -93,6 +97,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
className={css.actions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -230,7 +230,7 @@ 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, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
|
||||
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: 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)
|
||||
@@ -380,6 +380,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -388,7 +390,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
|
||||
}
|
||||
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
|
||||
if (node.kind === 'tool-result') return null
|
||||
return <MessageItem key={item.key} node={node} />
|
||||
return <MessageItem key={item.key} node={node} onFork={forkAt} />
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Shared IconActions chrome for user and assistant messages: copy / branch
|
||||
// live (branch still a stub), date-aware clock, optional edit stub.
|
||||
// Shared IconActions chrome for user and assistant messages: copy live,
|
||||
// branch wired through onBranch, date-aware clock,
|
||||
// optional edit stub.
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
@@ -18,17 +19,19 @@ export interface MessageIconActionsProps {
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Fork the session at this message. */
|
||||
onBranch?: (() => void) | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy / branch (/ clock) IconActions row shared by user and assistant chrome.
|
||||
* @param props - Copy text, event time, clock side, optional edit, className.
|
||||
* @param props - Copy text, event time, clock side, optional edit, branch callback, className.
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, edit, className,
|
||||
text, time, clock, edit, onBranch, className,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const onCopy = useCallback(() => {
|
||||
@@ -48,7 +51,7 @@ export function MessageIconActions({
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支" onClick={onBranch}>
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
@@ -16,6 +16,8 @@ import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
|
||||
/** Fork the session through the turn containing this message (user-bubble branch action). */
|
||||
onFork?: (seq: number) => void
|
||||
}
|
||||
|
||||
function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } {
|
||||
@@ -61,7 +63,7 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) {
|
||||
export const MessageItem = memo(function MessageItem({ node, onFork }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
@@ -76,6 +78,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
|
||||
time={node.time}
|
||||
clock="start"
|
||||
edit
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
className={css.actions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -419,6 +419,8 @@ export interface ChatViewInjected {
|
||||
*/
|
||||
openFile: (path: string) => void
|
||||
loadOlder: () => void
|
||||
/** Fork the session through the turn containing the message at `seq`, then open the child. */
|
||||
forkAt: (seq: number) => void
|
||||
}
|
||||
|
||||
/** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
padding-top: 2px;
|
||||
border-radius: 14px 14px 0 0;
|
||||
background: var(--dsw-specific-tip);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
.panel::after {
|
||||
@@ -32,7 +34,52 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 4px 16px 4px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-label-tertiary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.header:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: Inter, var(--dsw-font-family);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.list {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
// 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 { useEffect, useId, useState } from 'react'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import css from './QueueDock.module.css'
|
||||
@@ -22,18 +23,28 @@ export interface QueueDockInjected {
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
|
||||
export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected
|
||||
|
||||
/** Queue strip: one preview line per queued message; renders null when the queue is empty. */
|
||||
/**
|
||||
* Queue strip: one item renders directly; multiple items default to a
|
||||
* collapsible count header; an empty queue renders nothing.
|
||||
*/
|
||||
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)
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
const listId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (queue.length === 0 && !collapsed) setCollapsed(true)
|
||||
if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null)
|
||||
}, [editing, queue])
|
||||
}, [collapsed, editing, queue])
|
||||
|
||||
if (queue.length === 0) return null
|
||||
|
||||
const interactionActive = editing !== null || busy !== null
|
||||
const expanded = !collapsed || interactionActive
|
||||
const listVisible = queue.length === 1 || expanded
|
||||
|
||||
const applyAction = async (
|
||||
itemId: QueueItemId,
|
||||
action: QueueAction,
|
||||
@@ -63,8 +74,23 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
return (
|
||||
<div className={css.dock}>
|
||||
<div className={css.panel}>
|
||||
<ul className={css.list}>
|
||||
{queue.map(row => (
|
||||
{queue.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-controls={listId}
|
||||
aria-expanded={expanded}
|
||||
disabled={interactionActive}
|
||||
onClick={() => { setCollapsed(value => !value) }}
|
||||
>
|
||||
<span className={css.count}>{queue.length} 条排队消息</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{expanded ? <IconChevronDownOutline14 /> : <IconChevronUpOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<ul id={listId} className={css.list} hidden={!listVisible}>
|
||||
{listVisible && queue.map(row => (
|
||||
<li key={row.id} className={css.row}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
|
||||
@@ -19,6 +19,13 @@
|
||||
border-radius: 20px;
|
||||
background: var(--dsw-specific-input-major);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
|
||||
the justification or command passes the cap, so the thumb takes the l2
|
||||
pair. Declared on the card because the elevation belongs to the surface,
|
||||
and the custom properties inherit down to the region that actually
|
||||
scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Tinted full-width header band. */
|
||||
@@ -40,11 +47,22 @@
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
/* Scroll region: an agent's justification and its command are unbounded model
|
||||
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
|
||||
fixed-height column — uncapped, a long command pushed the action row past
|
||||
the viewport and the approval could not be answered at all. The strip and
|
||||
the action row stay outside, so the buttons are always on screen. */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 16px 14px;
|
||||
/* border-box so the cap is the region's OUTER height: the composer's draft
|
||||
area counts its padding inside the same number, and the two seats are
|
||||
only interchangeable if they occupy the same box. */
|
||||
box-sizing: border-box;
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
/* The model's justification is the panel's message, not a footnote. */
|
||||
@@ -63,11 +81,15 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Card-level row, not body content. Its padding reproduces the metrics the row
|
||||
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
|
||||
margin, neither of which reaches it out here) and the body's former 14px
|
||||
bottom pad below, so the resting card is unchanged. */
|
||||
.actionRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding: 14px 16px 14px;
|
||||
}
|
||||
|
||||
.allow,
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
// pending, this panel occupies the composer slot in place of the InputBar:
|
||||
// an amber "Waiting for approval" strip on the card top, the model's
|
||||
// justification as the headline, the paired command in muted code text, and
|
||||
// a right-aligned refuse/allow action row. One-shot: the buttons disable
|
||||
// a right-aligned refuse/allow action row. Justification and command are
|
||||
// unbounded model text, so they scroll inside the card at the shared composer
|
||||
// cap (`data-approval-scroll`) and the action row stays outside it — the
|
||||
// buttons must be reachable no matter how long the command is.
|
||||
// One-shot: the buttons disable
|
||||
// after a click and the panel leaves (the InputBar returns) on the broadcast
|
||||
// resolved frame. The draft's "Always allow this type" is deferred with
|
||||
// grant storage.
|
||||
@@ -53,17 +57,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
|
||||
<div className={css.root} data-approval-key={pending.key}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><span className={css.dot} />等待审批</div>
|
||||
<div className={css.body}>
|
||||
{/* Tab stop: the region scrolls once the command passes the cap and
|
||||
holds nothing focusable of its own, so without one a keyboard-only
|
||||
user cannot reach the command's tail before answering. */}
|
||||
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
|
||||
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
|
||||
{command !== undefined && <div className={css.command}>{command}</div>}
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.actionRow}>
|
||||
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
|
||||
拒绝
|
||||
</button>
|
||||
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
|
||||
允许一次
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +143,14 @@
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
/* One cap for every scrolling text region a composer seat can hold: the
|
||||
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
|
||||
takeover panels' bodies top out at the same height, so electing a
|
||||
takeover never grows the footer past the card it replaces. Declared on
|
||||
the seat because it is the chain's only shared ancestor — fallback and
|
||||
elected overlay are siblings — and custom properties inherit down to
|
||||
whichever entry is mounted. */
|
||||
--dsh-composer-text-max-height: 336px;
|
||||
}
|
||||
|
||||
/* Active phase: header is ordinary column chrome above the scrollport (not
|
||||
|
||||
@@ -209,7 +209,9 @@
|
||||
.mirror {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
max-height: 336px;
|
||||
/* 14-line cap, shared with the composer takeovers (declared on
|
||||
ConversationRoot .composerSeat). */
|
||||
max-height: var(--dsh-composer-text-max-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,13 @@ describe('conversation slot inject surface', () => {
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
chatView.injected.forkAt(17)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
|
||||
})
|
||||
expect(b.runtime.sessions.calls).toContainEqual({
|
||||
method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
|
||||
})
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
const openDetails = vi.fn<(t: SelectionTarget) => void>()
|
||||
const openFile = vi.fn<(path: string) => void>()
|
||||
const loadOlder = vi.fn()
|
||||
const forkAt = vi.fn()
|
||||
// Selection rides the REAL chat store (same construction path as
|
||||
// production; the view reads it through the PropsStore useStore share).
|
||||
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
|
||||
@@ -120,9 +121,10 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
|
||||
openDetails,
|
||||
openFile,
|
||||
loadOlder,
|
||||
forkAt,
|
||||
}
|
||||
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
|
||||
return { set, ChatView, props, openDetails, openFile, loadOlder, forkAt, setSelection }
|
||||
}
|
||||
|
||||
describe('chat-flow derivation', () => {
|
||||
@@ -228,6 +230,16 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByRole('button', { name: '在新对话中分支' })).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('forks from both user and finalized assistant message actions at their event seq', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt.mock.calls).toEqual([[1], [2]])
|
||||
})
|
||||
|
||||
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
|
||||
const markdown = '# Rendered\n\n- **one**\n- `two`'
|
||||
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* QueueDock rendering and operations: authoritative rows, inline editing,
|
||||
* removal, failure notices, and live retirement.
|
||||
* collapse state, removal, failure notices, and live retirement.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
@@ -78,16 +78,109 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders one row directly and defaults multiple rows to a collapsible count header', () => {
|
||||
const single = snapshotWith([row('i-1', 'one')])
|
||||
const source = liveSession(single)
|
||||
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
|
||||
expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull()
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
|
||||
act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) })
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy()
|
||||
expect(view.queryByText('one')).toBeNull()
|
||||
expect(view.queryByText('two')).toBeNull()
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
expect(view.getByText('two')).toBeTruthy()
|
||||
|
||||
fireEvent.click(header)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('one')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an active single-row editor visible when another item arrives', () => {
|
||||
const single = snapshotWith([row('i-edit', 'before')])
|
||||
const source = liveSession(single)
|
||||
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} />)
|
||||
|
||||
fireEvent.click(view.getByLabelText('编辑排队消息'))
|
||||
fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } })
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header).toHaveProperty('disabled', true)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft')
|
||||
expect(view.getByText('second')).toBeTruthy()
|
||||
|
||||
fireEvent.click(view.getByLabelText('取消编辑'))
|
||||
expect(header).toHaveProperty('disabled', false)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('second')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an in-flight row action visible when another item arrives', async () => {
|
||||
const single = snapshotWith([row('i-remove', 'remove me')])
|
||||
const source = liveSession(single)
|
||||
let finishUpdate: (() => void) | undefined
|
||||
const updateQueue = vi.fn(() => new Promise<void>((resolve) => { finishUpdate = resolve }))
|
||||
const view = render(
|
||||
<QueueDock {...kitFor(single, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(view.getByLabelText('删除排队消息'))
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header).toHaveProperty('disabled', true)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(view.getByText('remove me')).toBeTruthy()
|
||||
expect(view.getByText('second')).toBeTruthy()
|
||||
|
||||
act(() => { finishUpdate?.() })
|
||||
await waitFor(() => {
|
||||
expect(header).toHaveProperty('disabled', false)
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults a new multi-row queue to collapsed after the prior queue empties', () => {
|
||||
const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
|
||||
const source = liveSession(first)
|
||||
const view = render(<QueueDock {...kitFor(first)} useSession={source.useSession} />)
|
||||
fireEvent.click(view.getByRole('button', { name: '2 条排队消息' }))
|
||||
expect(view.getByText('one')).toBeTruthy()
|
||||
|
||||
act(() => { source.push(snapshotWith([])) })
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
act(() => {
|
||||
source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')]))
|
||||
})
|
||||
|
||||
const header = view.getByRole('button', { name: '2 条排队消息' })
|
||||
expect(header.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(view.queryByText('three')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders active actions and disables editing for mixed-content rows', () => {
|
||||
const snap = snapshotWith([
|
||||
row('i-1', '第一条排队消息'),
|
||||
row('i-2', null, 'image [image]'),
|
||||
])
|
||||
const source = liveSession(snap)
|
||||
const { container } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
const { container, getByRole } = render(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
expect([...container.querySelectorAll('li')].map(item => item.textContent))
|
||||
.toEqual(['第一条排队消息', 'image [image]'])
|
||||
expect(container.querySelectorAll('button')).toHaveLength(4)
|
||||
expect(container.querySelectorAll('button')).toHaveLength(5)
|
||||
expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0)
|
||||
@@ -162,10 +255,11 @@ describe('QueueDock', () => {
|
||||
const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')])
|
||||
const source = liveSession(snap)
|
||||
const updateQueue = vi.fn(() => Promise.resolve())
|
||||
const { getAllByLabelText } = render(
|
||||
const { getAllByLabelText, getByRole } = render(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
fireEvent.click(getByRole('button', { name: '2 条排队消息' }))
|
||||
fireEvent.click(getAllByLabelText('删除排队消息')[0]!)
|
||||
await waitFor(() => {
|
||||
expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })
|
||||
|
||||
Reference in New Issue
Block a user