Merge remote-tracking branch 'origin/master' into worktree/web-model-request-retry
# Conflicts: # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/index.ts # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
This commit is contained in:
@@ -13,6 +13,8 @@ import { InputHub } from './input/hub.ts'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { queueDockEntry } from './queue/QueueDock.tsx'
|
||||
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { ConversationSession } from './skeleton/ConversationSession.tsx'
|
||||
@@ -179,9 +181,16 @@ export function apply(ctx: Context): void {
|
||||
// 'conversation.chat.toolview' declaration) is on the ledger.
|
||||
ctx.plugin(ConversationService, { input: inputHub })
|
||||
|
||||
// The bash sample rides that exact seam, in third-party posture.
|
||||
// The bash sample rides that exact seam, in third-party posture
|
||||
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
|
||||
ctx.plugin(bashToolviewSample)
|
||||
|
||||
// The todo_write row rides the same seam (a product registration, not a sample).
|
||||
ctx.plugin(todoToolview)
|
||||
|
||||
// The plan strip rides the input dock above the queue rows (same posture).
|
||||
ctx.plugin(todoDockEntry)
|
||||
|
||||
// The read-only queue dock entry (T9 file territory) rides the same
|
||||
// registration seam into the input dock declared above.
|
||||
ctx.plugin(queueDockEntry)
|
||||
|
||||
@@ -37,17 +37,11 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Selection linkage: the selected call row wears the blue outline.
|
||||
button-info-fill flips 500→400 with the theme, hitting the darker-blue
|
||||
dark-mode spec exactly (business-primary stays 500 on both). */
|
||||
.callRow[data-selected] {
|
||||
outline: 1.5px solid var(--dsw-alias-button-info-fill);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* Selection still sets data-selected for details linkage; no outline —
|
||||
tool rows match Think chrome (no selected ring). */
|
||||
|
||||
/* run_code sub-dispatch rows: indented under the parent row, left-edged so
|
||||
the code turn reads as one unit; each nested row is itself a .callRow
|
||||
(same components, same selection outline as top-level rows). */
|
||||
the code turn reads as one unit; each nested row is itself a .callRow. */
|
||||
.subCalls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/* User bubble: right-aligned, figma r22 fill = the bubble specific token
|
||||
(#EDF3FE light / dark pair rides the token sheet). */
|
||||
/* User bubble: right-aligned column (bubble + IconActions). Figma
|
||||
User_Bubble/message_container 659:38813 — r22 fill, actions gap 6 below. */
|
||||
|
||||
/* Block spacing is the flow column's gap alone — no extra padding here. */
|
||||
.userRow {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
@@ -19,6 +20,46 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until the row is hovered/focused. Touch /
|
||||
hover:none keeps actions visible (opacity:0 still hit-tests). */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.userRow:hover .actions,
|
||||
.userRow:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: 28px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned), steering
|
||||
// (badged bubble), context injection, retry disclosure and unknown JSON rows.
|
||||
// Props are frozen node slices off the snapshot cache; memo holds across
|
||||
// streaming because unchanged nodes keep their references.
|
||||
// MessageItem: simple chat nodes — user bubble (right-aligned, with copy /
|
||||
// branch / edit IconActions), steering (badged bubble), context injection,
|
||||
// retry disclosure and unknown-surface JSON rows. Props are frozen node slices
|
||||
// off the snapshot cache; memo holds across streaming because unchanged nodes
|
||||
// keep their references.
|
||||
|
||||
import { memo, useEffect, useState, type ReactNode } from 'react'
|
||||
import { memo, useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
IconBranchOutline16, IconCopyOutline16, IconEditOutline16,
|
||||
JsonBlock, MessageText, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './MessageItem.module.css'
|
||||
|
||||
export interface MessageItemProps {
|
||||
@@ -47,15 +51,19 @@ function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolea
|
||||
: retrySeconds(deadline - Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || retrySeconds(deadline - Date.now()) === 1) return
|
||||
const timer = window.setInterval(() => {
|
||||
if (!active) return
|
||||
const updateCountdown = (): number => {
|
||||
const next = retrySeconds(deadline - Date.now())
|
||||
setCountdown(current => (
|
||||
current.deadline === deadline && current.seconds === next
|
||||
? current
|
||||
: { deadline, seconds: next }
|
||||
))
|
||||
if (next === 1) window.clearInterval(timer)
|
||||
return next
|
||||
}
|
||||
if (updateCountdown() === 1) return
|
||||
const timer = window.setInterval(() => {
|
||||
if (updateCountdown() === 1) window.clearInterval(timer)
|
||||
}, 250)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [active, deadline])
|
||||
@@ -75,6 +83,35 @@ function ModelRetryItem({ node, active }: { node: ModelRetryNode; active: boolea
|
||||
)
|
||||
}
|
||||
|
||||
/** Best-effort clipboard write; rejections stay swallowed (no success chrome). */
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch {
|
||||
// Denied permissions / iframe policy.
|
||||
}
|
||||
return
|
||||
}
|
||||
const exec = typeof document.execCommand === 'function'
|
||||
? document.execCommand.bind(document)
|
||||
: undefined
|
||||
if (exec === undefined) return
|
||||
const el = document.createElement('textarea')
|
||||
el.value = text
|
||||
el.setAttribute('readonly', '')
|
||||
el.style.position = 'fixed'
|
||||
el.style.left = '-9999px'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
exec('copy')
|
||||
} catch {
|
||||
// Clipboard unavailable; the button stays idle.
|
||||
}
|
||||
el.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Display projection of reference forms in a user bubble (free geometry — no
|
||||
* textarea alignment constraint here); everything else stays plain text. The
|
||||
@@ -107,15 +144,52 @@ function projectUserText(text: string): ReactNode {
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
/** User-bubble IconActions (figma 659:38820): copy is live; branch/edit are chrome stubs. */
|
||||
function UserActions({ text }: { text: string }) {
|
||||
const onCopy = useCallback(() => {
|
||||
void writeClipboard(text)
|
||||
}, [text])
|
||||
return (
|
||||
<div className={css.actions}>
|
||||
<Tooltip label="复制" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="复制" onClick={onCopy}>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="在新对话中分支" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="在新对话中分支">
|
||||
<IconBranchOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="编辑" side="bottom">
|
||||
<button type="button" className={css.action} aria-label="编辑">
|
||||
<IconEditOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({ node, retryActive = false }: MessageItemProps) {
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
case 'user': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
</div>
|
||||
<UserActions text={text} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'steering': {
|
||||
const { text, rest } = contentText(node.content)
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
{node.kind === 'steering' && <span className={css.badge}>插话</span>}
|
||||
<span className={css.badge}>插话</span>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label="附加内容块" payload={block} />)}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function QueueDock({ useSession }: QueueDockProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The dock entry as a plain registrant plugin (bash-sample posture).
|
||||
* 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.
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
/* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by
|
||||
the chat scroller. Top 8 hosts the error strip's breathing room. */
|
||||
padding: 8px 32px 12px;
|
||||
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;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -323,7 +324,7 @@
|
||||
.stopping,
|
||||
.stopping:hover {
|
||||
background: var(--dsw-alias-button-primary-dimmed);
|
||||
color: var(--dsw-alias-brand-text);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.retry {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* 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. */
|
||||
|
||||
.root {
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
width: calc(100% - 88px);
|
||||
max-width: 776px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.progress {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.glyph {
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.glyphCompleted {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.glyphProgress {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
animation: todo-progress-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.glyphPending {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes todo-progress-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Figma strip is single-line; long items ellipsize with no inline expand. */
|
||||
.content {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// TodoPanel: persistent plan strip above the composer (the web counterpart
|
||||
// of the TUI plan panel). Renders the latest todo/write whole-list snapshot —
|
||||
// no data of its own, hidden while the list is empty. Mounted through the
|
||||
// 'conversation.input.dock' slot (QueueDock posture): the dock adapter does
|
||||
// the selecting, so the panel takes the plain list and stays framework-free.
|
||||
// Visual: figma 772:51905 (states) / 772:52972 (collapsed) / 772:53419 (expanded).
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './TodoPanel.module.css'
|
||||
|
||||
export interface TodoPanelProps {
|
||||
/** The session's current plan (empty renders nothing) — selected by the dock adapter. */
|
||||
todos: readonly TodoItem[]
|
||||
}
|
||||
|
||||
/** Local exhaustiveness helper — client packages do not depend on `dsh-llm`. */
|
||||
/* v8 ignore next 3 -- closed-union backstop; only reached if status is forged */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unreachable todo status: ${String(value)}`)
|
||||
}
|
||||
|
||||
/** Status glyphs share the figma 14×14 artboard; the 16×16 `.glyph` cell centers them. */
|
||||
function CompletedGlyph() {
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphCompleted}>
|
||||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" />
|
||||
<path
|
||||
d="M10.9631 5.71411L7.70154 8.97571C7.48011 9.19714 7.27736 9.40099 7.09229 9.54993C6.89742 9.70669 6.66314 9.85279 6.3634 9.90027C6.2049 9.92534 6.04339 9.92534 5.88489 9.90027C5.58515 9.85279 5.35087 9.70669 5.15601 9.54993C4.97093 9.40099 4.76818 9.19714 4.54675 8.97571L3.03516 7.46411L3.96313 6.53613L5.47473 8.04773C5.7169 8.28989 5.86196 8.43389 5.97888 8.52795C6.08597 8.61409 6.10875 8.60701 6.08997 8.604C6.11259 8.60758 6.13571 8.60758 6.15833 8.604C6.13954 8.60701 6.16232 8.61409 6.26941 8.52795C6.38633 8.43389 6.53139 8.28989 6.77356 8.04773L10.0352 4.78613L10.9631 5.71411Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** In-progress: business-blue ring fading out; CSS spins the svg. */
|
||||
function ProgressGlyph() {
|
||||
const gradientId = useId()
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphProgress}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="2.5" y1="12" x2="10.5" y2="3.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="currentColor" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="7" cy="7" r="6.4" stroke={`url(#${gradientId})`} strokeWidth="1.2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */
|
||||
function PendingGlyph() {
|
||||
return (
|
||||
<svg width={14} height={14} viewBox="0 0 14 14" fill="none" aria-hidden="true" className={css.glyphPending}>
|
||||
<circle cx="7" cy="7" r="6.4" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2.4 2.4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusGlyph({ status }: { status: TodoItem['status'] }) {
|
||||
switch (status) {
|
||||
case 'completed': return <CompletedGlyph />
|
||||
case 'in_progress': return <ProgressGlyph />
|
||||
case 'pending': return <PendingGlyph />
|
||||
/* v8 ignore next -- closed TodoItem status union */
|
||||
default: return assertNever(status)
|
||||
}
|
||||
}
|
||||
|
||||
/** Header summary: "<done>/<total> tasks · <n> in progress". */
|
||||
function progressLabel(todos: readonly TodoItem[]): string {
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.filter(t => t.status === 'in_progress').length
|
||||
return `${done}/${todos.length} tasks · ${active} in progress`
|
||||
}
|
||||
|
||||
export function TodoPanel({ todos }: TodoPanelProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
if (todos.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className={css.root} data-testid="todo-panel" aria-label="To-dos">
|
||||
<div className={css.body}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.header}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => { setCollapsed(v => !v) }}
|
||||
>
|
||||
<span className={css.title}>To-dos</span>
|
||||
<span className={css.progress}>{progressLabel(todos)}</span>
|
||||
<span className={css.chevron} aria-hidden>
|
||||
{collapsed ? <IconChevronUpOutline14 /> : <IconChevronDownOutline14 />}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className={css.list}>
|
||||
{todos.map(item => (
|
||||
<li key={item.content} className={css.item} data-status={item.status}>
|
||||
<span className={css.glyph} aria-hidden><StatusGlyph status={item.status} /></span>
|
||||
<span className={css.content}>{item.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */
|
||||
export type TodoDockProps = PropsRuntime<'conversation.input.dock'>
|
||||
|
||||
/** Dock adapter: selects the plan off the session snapshot and hands the strip a plain list. */
|
||||
export function TodoDock({ useSession }: TodoDockProps) {
|
||||
const todos = useSession(s => s.todos)
|
||||
return <TodoPanel todos={todos} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan strip as a plain registrant plugin (QueueDock 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.
|
||||
*/
|
||||
export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the plan strip into the input dock (list entry, above the queue rows).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
},
|
||||
}
|
||||
@@ -1,29 +1,32 @@
|
||||
/* Sample bash rows: deliberately distinct from ToolRow so the differential
|
||||
registry hit is visible at a glance. */
|
||||
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
|
||||
|
||||
.row {
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
.root:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
.leading {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.scopeBadge {
|
||||
flex: none;
|
||||
margin-right: 8px;
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
@@ -32,17 +35,38 @@
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.command {
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
margin: 0 8px;
|
||||
background: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.err {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,54 @@
|
||||
// Bash toolview sample, written in third-party posture: everything below uses
|
||||
// only the public slot surface (ctx.slots.register into the keyed
|
||||
// 'conversation.chat.toolview' hole + ToolRowProps) — the acceptance proof
|
||||
// that a plain plugin can take over a tool row with zero dedicated machinery.
|
||||
// Session-dimension differentiation happens INSIDE the component (the
|
||||
// canonical sub-agent scenario): rows in child sessions render the scoped
|
||||
// variant, derived from the standard useSessions kit — no registry predicates.
|
||||
// Bash toolview registrant: third-party posture over the keyed toolview hole
|
||||
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
|
||||
// Product chrome matches ToolRow / Think (figma: Bash · {description}).
|
||||
// Child sessions keep a scoped badge so session-dimension differentiation stays
|
||||
// observable inside the component (no parallel registry).
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
|
||||
import css from './bash-sample.module.css'
|
||||
|
||||
/** Bash row: command-first monospace summary replacing the generic card.
|
||||
* Sub-session rows (parentId present) swap the prompt for a scoped badge —
|
||||
* the differential stays observable per session from one registration. */
|
||||
function leadingFor(state: ToolRowState) {
|
||||
switch (state) {
|
||||
case 'running': return <StateDot state="ongoing" />
|
||||
case 'error': return <StateDot state="error" />
|
||||
case 'stopped': return <StateDot state="warning" />
|
||||
default: return <IconApiOutline14 size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
|
||||
function stateStatus(state: ToolRowState): string | null {
|
||||
switch (state) {
|
||||
case 'running': return '运行中'
|
||||
case 'error': return '失败'
|
||||
case 'stopped': return '已停止'
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
|
||||
export function BashRow({ toolName, block, openDetails, sessionId, useSessions }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
|
||||
if (isChild) {
|
||||
return (
|
||||
<div className={css.row} data-sample="bash-scoped" onClick={openDetails}>
|
||||
<span className={css.scopeBadge}>scoped</span>
|
||||
<span className={css.command}>{model.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const status = stateStatus(model.state)
|
||||
return (
|
||||
<div className={css.row} data-sample="bash-global" onClick={openDetails}>
|
||||
<span className={css.prompt} aria-hidden>$</span>
|
||||
<span className={css.command}>{model.summary}</span>
|
||||
{model.state === 'error' && <span className={css.err}>failed</span>}
|
||||
<div
|
||||
className={css.root}
|
||||
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
|
||||
data-variant="bash"
|
||||
data-state={model.state}
|
||||
data-clickable
|
||||
onClick={openDetails}
|
||||
>
|
||||
<span className={css.leading}>{leadingFor(model.state)}</span>
|
||||
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
|
||||
{isChild && <span className={css.scopeBadge}>scoped</span>}
|
||||
<span className={css.title}>{model.title}</span>
|
||||
<span className={css.sep} aria-hidden />
|
||||
<span className={css.summary}>{model.summary}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* todo_write plan-update row: title + progress summary on one line. */
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-weight: 510;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.summary {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.err {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// todo_write toolview: plan-flavored summary row replacing the generic
|
||||
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// summarizes the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line.
|
||||
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import css from './todo-row.module.css'
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
interface TodoWriteItem { content?: unknown; status?: unknown }
|
||||
|
||||
function isItem(value: unknown): value is TodoWriteItem {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function summarize(argsRaw: string): string | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(argsRaw)
|
||||
} catch {
|
||||
// Mid-stream truncation or malformed model JSON: fall back to the generic summary.
|
||||
return null
|
||||
}
|
||||
// Valid JSON with an invalid shape (null root, non-array todos, null items —
|
||||
// a rejected tool/call retains such args verbatim): same generic fallback.
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const todos = (parsed as { todos?: unknown }).todos
|
||||
if (!Array.isArray(todos) || !todos.every(isItem)) return null
|
||||
const done = todos.filter(t => t.status === 'completed').length
|
||||
const active = todos.find(t => t.status === 'in_progress')
|
||||
const head = `${done}/${todos.length} 已完成`
|
||||
return typeof active?.content === 'string' && active.content !== ''
|
||||
? `${head} · ${active.content}`
|
||||
: head
|
||||
}
|
||||
|
||||
/** One-line plan update row (click opens the raw args in details). Non-ok
|
||||
* execution states keep the generic row's dot semantics — a cancelled call
|
||||
* wrote no todo/write, so it must not read as a completed update. */
|
||||
export function TodoRow({ toolName, block, openDetails }: ToolRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw) ?? model.summary
|
||||
// Button semantics, not a <button>: the row carries inline spans a button
|
||||
// would flatten, and ToolRow takes the same role/tabIndex/Enter-Space route.
|
||||
const openFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
openDetails()
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={css.row}
|
||||
data-sample="todo-row"
|
||||
data-state={model.state}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openDetails}
|
||||
onKeyDown={openFromKeyboard}
|
||||
>
|
||||
{model.state === 'ok'
|
||||
? <span className={css.badge} aria-hidden>☰</span>
|
||||
: <StateDot state={model.state === 'running' ? 'ongoing' : model.state === 'stopped' ? 'warning' : 'error'} />}
|
||||
<span className={css.title}>更新任务清单</span>
|
||||
<span className={css.summary}>{summary}</span>
|
||||
{model.state === 'error' && <span className={css.err}>failed</span>}
|
||||
{model.state === 'stopped' && <span className={css.err}>已中断</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The todo row as a plain registrant plugin, riding the same load-order seam
|
||||
* as the bash sample: `inject: ['conversation']` guarantees the chat entry
|
||||
* (and with it the 'conversation.chat.toolview' declaration) is on the ledger.
|
||||
*/
|
||||
export const todoToolview = {
|
||||
name: 'todo-toolview',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the todo row into the chat view's keyed toolview hole.
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user