optimize chat ui

This commit is contained in:
07akioni
2026-07-27 12:02:28 +08:00
parent f12a014481
commit 3ee2982f85
31 changed files with 759 additions and 186 deletions
@@ -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 h4h6 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 {