optimize chat ui
This commit is contained in:
@@ -179,7 +179,8 @@ 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 read-only queue dock entry (T9 file territory) rides the same
|
||||
|
||||
@@ -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,40 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 28px;
|
||||
/* Hidden until the row is hovered/focused (web-styling message action bar). */
|
||||
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,14 +1,18 @@
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned),
|
||||
// steering (badged bubble), context injection and unknown-surface JSON rows.
|
||||
// Props are frozen node slices off the snapshot cache; memo holds across
|
||||
// streaming because unchanged nodes keep their references.
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
||||
// copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// injection 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 } from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ContextMessageNode, 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 {
|
||||
@@ -26,6 +30,30 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
|
||||
return { text: texts.join(''), rest }
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
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
|
||||
@@ -58,15 +86,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 }: 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>
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
.stopping,
|
||||
.stopping:hover {
|
||||
background: var(--dsw-alias-button-primary-dimmed);
|
||||
color: var(--dsw-alias-brand-text);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.retry {
|
||||
|
||||
@@ -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,29 @@
|
||||
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);
|
||||
}
|
||||
|
||||
.err {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font-size: 11px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
// 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} />
|
||||
}
|
||||
}
|
||||
|
||||
/** 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
// Remaining chat branch tails: MessageItem context/unknown/steering arms,
|
||||
// StatsLine no-cache join, PendingCard reason strip, AssistantMarkdown
|
||||
// single-line reasoning. (Tool-row dispatch tails live with the keyed-slot
|
||||
// machinery specs since the tool ring dissolved into renderSlot.)
|
||||
// user IconActions, StatsLine no-cache join, PendingCard reason strip,
|
||||
// AssistantMarkdown single-line reasoning. (Tool-row dispatch tails live
|
||||
// with the keyed-slot machinery specs since the tool ring dissolved into
|
||||
// renderSlot.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -18,7 +19,75 @@ import { StatsLine, type StatsLineProps } from '../src/client/chat/StatsLine.tsx
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks', () => {
|
||||
it('user bubbles expose copy / branch / edit actions; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '编辑' })).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
})
|
||||
|
||||
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
const exec = vi.fn().mockReturnValue(true)
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: exec,
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'fallback body' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
})
|
||||
render(
|
||||
<MessageItem node={{
|
||||
kind: 'user', seq: 1,
|
||||
content: [{ type: 'text', text: 'quiet' }] as never,
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
})
|
||||
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
|
||||
const view = render(
|
||||
<MessageItem node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
@@ -29,6 +98,7 @@ describe('MessageItem arms', () => {
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context and unknown nodes render their JSON rows', () => {
|
||||
|
||||
@@ -156,12 +156,13 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(view.getByText('List the notes directory')).toBeTruthy()
|
||||
|
||||
// Nested rows are ALWAYS visible (no parent expand needed): the bash
|
||||
// sub-call landed in the bash sample plugin's keyed registration — the
|
||||
// exact component a native top-level bash row uses — and the unregistered
|
||||
// sub-call landed in the bash sample plugin's keyed registration — Bash ·
|
||||
// description chrome, same as a top-level bash row — and the unregistered
|
||||
// sub-tool fell back to GenericToolCard at the same render site.
|
||||
const nest = view.container.querySelector('[data-subcalls]')
|
||||
expect(nest).not.toBeNull()
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('List notes')).toBeTruthy()
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -169,17 +169,19 @@ describe('bash sample row', () => {
|
||||
expect(view.container.querySelector('[data-sample="bash-scoped"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('summarizes the command and hands clicks to openDetails on both arms', () => {
|
||||
it('summarizes as Bash · description and hands clicks to openDetails on both arms', () => {
|
||||
const openGlobal = vi.fn()
|
||||
const global = render(<BashRow {...rowProps(ROOT, { openDetails: openGlobal })} />)
|
||||
// Two renders share document.body: query inside each container.
|
||||
const globalRow = global.container.querySelector('[data-sample="bash-global"]')!
|
||||
expect(globalRow.textContent).toContain('Bash')
|
||||
expect(globalRow.textContent).toContain('Build')
|
||||
fireEvent.click(globalRow)
|
||||
expect(openGlobal).toHaveBeenCalledTimes(1)
|
||||
const openScoped = vi.fn()
|
||||
const scoped = render(<BashRow {...rowProps(CHILD, { openDetails: openScoped })} />)
|
||||
const scopedRow = scoped.container.querySelector('[data-sample="bash-scoped"]')!
|
||||
expect(scopedRow.textContent).toContain('Bash')
|
||||
expect(scopedRow.textContent).toContain('Build')
|
||||
fireEvent.click(scopedRow)
|
||||
expect(openScoped).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
// bash: the sample plugin's keyed registration took the row (root
|
||||
// session → global arm, decided inside the component off useSessions).
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('Bash')).toBeTruthy()
|
||||
expect(view.getByText('Build')).toBeTruthy()
|
||||
// mystery: no registration under that key → render-site fallback.
|
||||
expect(view.getByText('Tool call')).toBeTruthy()
|
||||
|
||||
@@ -264,7 +264,7 @@ describe('ChatView', () => {
|
||||
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clicking a tool row opens details with callId and toolName; selection paints the outline', () => {
|
||||
it('clicking a tool row opens details with callId and toolName; selection marks data-selected', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('run a'))
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
|
||||
// PendingCard question arm, bash sample error pill, the node-half empty
|
||||
// PendingCard question arm, bash sample state dots, the node-half empty
|
||||
// apply, and AssistantMarkdown reasoning/unknown block arms.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -76,14 +76,7 @@ describe('tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('BashRow shows the failed pill on error results (root session arm)', () => {
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
// Root session (no parentId): the global arm renders, error pill visible.
|
||||
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => {
|
||||
const sid = 'root-1' as SessionId
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid],
|
||||
@@ -91,12 +84,38 @@ describe('tails', () => {
|
||||
current: undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState)
|
||||
const props = {
|
||||
callId: 'c1', toolName: 'bash', block: errorResult, openDetails: vi.fn(),
|
||||
const props = (block: RunningToolCall | ToolResultNode) => ({
|
||||
callId: 'c1', toolName: 'bash', block, openDetails: vi.fn(),
|
||||
sessionId: sid, useSessions: bindSnapshotSelector(list),
|
||||
} as unknown as ToolRowProps
|
||||
const view = render(<BashRow {...props} />)
|
||||
expect(view.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(view.getByText('failed')).toBeTruthy()
|
||||
} as unknown as ToolRowProps)
|
||||
|
||||
const running: RunningToolCall = {
|
||||
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
|
||||
turn: 1, step: 1, time: 1_000, callView: null,
|
||||
}
|
||||
const errorResult: ToolResultNode = {
|
||||
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
|
||||
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
|
||||
callTime: 500,
|
||||
content: [], isError: true, callView: null, resultView: null,
|
||||
}
|
||||
const stoppedResult: ToolResultNode = {
|
||||
...errorResult,
|
||||
error: { name: 'E', code: 'interrupted' },
|
||||
}
|
||||
|
||||
const runningView = render(<BashRow {...props(running)} />)
|
||||
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
|
||||
expect(runningView.getByText('Bash')).toBeTruthy()
|
||||
expect(runningView.getByText('List')).toBeTruthy()
|
||||
runningView.unmount()
|
||||
|
||||
const errorView = render(<BashRow {...props(errorResult)} />)
|
||||
expect(errorView.container.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
errorView.unmount()
|
||||
|
||||
const stoppedView = render(<BashRow {...props(stoppedResult)} />)
|
||||
expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
README.md: 4e2a22e77dc1611728477ea0a9d8c50dfc9f7f5d
|
||||
README.zh.md: 36253971281fd346f9b0ec4648c4b8824ed918a7
|
||||
README.md: 58e450451ab64f69762817dfb277b8a888e2177f
|
||||
README.zh.md: 6824f3efe4981adf9549941afa7e2f5db2ac005d
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -1,13 +1,79 @@
|
||||
/* One code-block geometry for highlighted and plain arms: the shiki <pre>
|
||||
and the fallback <pre> draw identically except for token colors. */
|
||||
/* Visual baseline: deepsuite `@deepseek/md` code-block.css. Highlight colors
|
||||
stay on the existing shiki `--shiki-*` sheet (not Prism highlight.css). */
|
||||
|
||||
.block {
|
||||
--dsl-code-block-banner-background-color: var(--dsw-alias-markdown-code-block-banner);
|
||||
--dsl-code-block-border-radius: 12px;
|
||||
--dsl-code-block-banner-font: var(--dsw-font-xs-13);
|
||||
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
|
||||
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-code-block-border-radius);
|
||||
}
|
||||
|
||||
.block:not(:last-child) {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.bannerWrap {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 6;
|
||||
background-color: var(--dsw-alias-bg-base);
|
||||
border-top-left-radius: var(--dsl-code-block-border-radius);
|
||||
border-top-right-radius: var(--dsl-code-block-border-radius);
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: var(--dsl-code-block-banner-background-color);
|
||||
padding: 9px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font: var(--dsl-code-block-banner-font);
|
||||
border-top-left-radius: var(--dsl-code-block-border-radius);
|
||||
border-top-right-radius: var(--dsl-code-block-border-radius);
|
||||
}
|
||||
|
||||
.infostring {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background-color: rgb(255 255 255 / 0);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.block :where(pre) {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
font: var(--dsl-code-block-content-font);
|
||||
padding: 16px;
|
||||
margin: 0 !important;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
}
|
||||
|
||||
/* Shiki inlines its theme background var; route it to the repo token. */
|
||||
@@ -23,5 +89,4 @@
|
||||
|
||||
.plain {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// CodeBlock: one code surface for every consumer — markdown fences, the
|
||||
// run_code program body, and the details panel's raw args/output — with
|
||||
// shiki highlighting for the registered grammars and an identical-geometry
|
||||
// plain fallback for everything else. Shiki emits a single <pre class="shiki">
|
||||
// tree of nested spans whose colors are --shiki-* custom properties
|
||||
// (token sheets own the values); it produces no scripts or event handlers,
|
||||
// so injecting its output is safe by construction.
|
||||
// plain fallback for everything else. Chrome (language banner + copy) matches
|
||||
// deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`.
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { highlightToHtml } from './highlight.ts'
|
||||
import css from './CodeBlock.module.css'
|
||||
@@ -20,18 +18,72 @@ export interface CodeBlockProps {
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
// jsdom and older hosts: best-effort execCommand path when present.
|
||||
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 (sandboxed iframe / denied permission); UI still
|
||||
// flips to the ok label so the gesture is acknowledged.
|
||||
}
|
||||
el.remove()
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, className }: CodeBlockProps) {
|
||||
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
|
||||
const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
|
||||
if (html === undefined) {
|
||||
return (
|
||||
<div className={clsx(css.block, className)}>
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
|
||||
typed fallback if the DOM shape ever diverges. */
|
||||
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
|
||||
void writeClipboard(text)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1000)
|
||||
}, [copied, trimmed])
|
||||
|
||||
const body = html === undefined
|
||||
? (
|
||||
<pre className={css.plain}><code>{trimmed}</code></pre>
|
||||
)
|
||||
: (
|
||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
||||
// span tree it generated from `code` (no user HTML passes through), the
|
||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={clsx(css.block, 'md-code-block', className)}>
|
||||
<div className={css.bannerWrap}>
|
||||
<div className={css.banner}>
|
||||
<div className={css.infostring}>{lang ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// eslint-disable-next-line react/no-danger -- shiki's output is a static
|
||||
// span tree it generated from `code` (no user HTML passes through), the
|
||||
// sanctioned innerHTML consumption path per shiki's own docs.
|
||||
return <div className={clsx(css.block, className)} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{body}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,95 +1,168 @@
|
||||
/* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
|
||||
Modules. Cite pills, KaTeX, header anchors, and thinking-small variants are
|
||||
intentionally absent (no matching DOM). Token names match that sheet. */
|
||||
|
||||
.markdown {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-base);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
|
||||
margin: 0;
|
||||
.markdown strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font: var(--dsw-font-markdown-h1);
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font: var(--dsw-font-markdown-h2);
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font: var(--dsw-font-markdown-h3);
|
||||
margin: 32px 0 16px;
|
||||
}
|
||||
|
||||
.markdown :where(h4, h5, h6) {
|
||||
.markdown h4 {
|
||||
font: var(--dsw-font-markdown-h4);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown :where(strong, th) {
|
||||
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
|
||||
.markdown :where(h5, h6) {
|
||||
font: var(--dsw-font-markdown-base-strong);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) {
|
||||
padding-inline-start: 24px;
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6) strong {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.markdown li + li {
|
||||
margin-block-start: 4px;
|
||||
.markdown p {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown li > :where(ul, ol) {
|
||||
margin-block-start: 4px;
|
||||
/* Tighten h4–h6 against a following list (design: 8px gap). */
|
||||
.markdown :where(h4, h5, h6) + :where(ul, ol) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
.markdown :where(h4, h5, h6):has(+ :where(ul, ol)) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
/* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet
|
||||
keeps design-platform brand-text as near-black, so links use the blue
|
||||
business-primary alias instead. */
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
transition: box-shadow var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
/* Transparent hit-area padding; literal zero-alpha only (no painted color). */
|
||||
border-left: 3px solid rgb(255 255 255 / 0);
|
||||
border-right: 3px solid rgb(255 255 255 / 0);
|
||||
border-top: 2px solid rgb(255 255 255 / 0);
|
||||
border-bottom: 2px solid rgb(255 255 255 / 0);
|
||||
margin-left: -3px;
|
||||
margin-right: -3px;
|
||||
}
|
||||
|
||||
.markdown :not(pre) > code {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-markdown-inline-code);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
.markdown a:hover,
|
||||
.markdown a:focus {
|
||||
outline: none;
|
||||
text-decoration: underline var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
.markdown a:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
white-space: pre;
|
||||
.markdown :where(ul, ol) {
|
||||
margin: 16px 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.markdown li:not(:first-child) {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.markdown li > :where(ul, ol) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.markdown li::marker {
|
||||
line-height: 28px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Nested ol under ul/ol: markers inside (models sometimes emit this shape). */
|
||||
.markdown :where(ul, ol) ol {
|
||||
list-style-position: inside;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) ol li p {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown li > p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.markdown li > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Keep list-nested code-block vertical margins (design: +4px vs other last children). */
|
||||
.markdown li > *:last-child:not(:global(.md-code-block)) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
|
||||
display: block;
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 32px 0;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
border-left: 2px solid var(--dsw-alias-label-caption);
|
||||
margin: 16px 0 0;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
margin: 16px 0;
|
||||
font-family: var(--ds-font-family-code);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown :not(pre) > code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
font: var(--dsw-font-markdown-code);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 0.875em !important;
|
||||
background-color: var(--dsw-alias-markdown-inline-code);
|
||||
border-radius: 6px;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6) code {
|
||||
font: inherit;
|
||||
font-family: var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-state-business-primary);
|
||||
accent-color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
@@ -99,22 +172,52 @@
|
||||
}
|
||||
|
||||
.tableScroll table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--dsw-font-markdown-table);
|
||||
}
|
||||
|
||||
.tableScroll :where(th, td) {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--dsw-alias-markdown-citation);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
width: max-content;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.tableScroll th {
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
text-align: start;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l3);
|
||||
border-top: none;
|
||||
font: var(--dsw-font-markdown-table-head);
|
||||
max-width: 320px;
|
||||
max-width: min(30vw, 320px);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.tableScroll td {
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
font: var(--dsw-font-markdown-table);
|
||||
max-width: 320px;
|
||||
max-width: min(30vw, 320px);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.tableScroll th:first-child,
|
||||
.tableScroll td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tableScroll td:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.tableScroll table code {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown > *:first-child,
|
||||
.markdown p:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.markdown > *:last-child,
|
||||
.markdown p:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.imageAlt {
|
||||
|
||||
@@ -5,14 +5,17 @@
|
||||
// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
|
||||
// alongside the rest of the markdown family.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
|
||||
import { highlightToHtml } from '../src/markdown/highlight.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('highlightToHtml', () => {
|
||||
it('highlights a registered grammar into css-variables token spans', () => {
|
||||
const html = highlightToHtml('const x: number = 1', 'typescript')
|
||||
@@ -50,4 +53,68 @@ describe('CodeBlock', () => {
|
||||
expect(view.container.querySelector('pre.shiki')).toBeNull()
|
||||
expect(view.getByText('plain text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the language banner and copies the pre textContent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
|
||||
expect(screen.getByText('ts')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('const a = 1')
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
// While the ok label is showing, further clicks are no-ops.
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
const exec = vi.fn().mockReturnValue(true)
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: exec,
|
||||
})
|
||||
render(<CodeBlock code="plain body" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('still acknowledges copy when execCommand throws', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: () => {
|
||||
throw new Error('denied')
|
||||
},
|
||||
})
|
||||
render(<CodeBlock code="plain body" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('acknowledges copy when neither clipboard API nor execCommand exists', () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
render(<CodeBlock code="plain body" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,8 +57,10 @@ describe('MarkdownText', () => {
|
||||
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
|
||||
expect(container.querySelector('hr')).not.toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
|
||||
// The ts fence routed through the shared CodeBlock: shiki token spans present.
|
||||
// The ts fence routed through the shared CodeBlock: shiki token spans + banner.
|
||||
expect(container.querySelector('pre.shiki')).not.toBeNull()
|
||||
expect(screen.getByText('ts')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(container.querySelector('br')).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
--ds-font-family-code: 'SF Mono', 'JetBrains Mono', 'Fira Code', Consolas,
|
||||
'Liberation Mono', Menlo, Courier, 'PingFang SC', 'Microsoft YaHei';
|
||||
--ds-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ds-transition-duration: 0.2s;
|
||||
--ds-transition-duration-fast: 0.1s;
|
||||
--ds-transition-duration-slow: 0.3s;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user