Merge remote-tracking branch 'origin/master' into codex/fix-tui-diff-context-counts
# Conflicts: # packages/ui/tui/src/components/transcript.ts
This commit is contained in:
107
packages/client/ui-primitives/src/DiffBlock.module.css
Normal file
107
packages/client/ui-primitives/src/DiffBlock.module.css
Normal file
@@ -0,0 +1,107 @@
|
||||
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface +
|
||||
banner row, markdown code-block font) so a diff card reads as one family with
|
||||
a fenced block and a terminal card. The deliberate divergence, shared with
|
||||
TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally,
|
||||
because folding a source line destroys the indentation a diff is read by. */
|
||||
|
||||
.block {
|
||||
--dsl-diff-radius: 12px;
|
||||
--dsl-diff-line-height: 22px;
|
||||
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-diff-radius);
|
||||
}
|
||||
|
||||
/* The copy control floats in the top-right corner over the body, so the card
|
||||
has no empty banner row above its first diff line (the TUI diff card has no
|
||||
banner either — only the footer). The block is position: relative, so this
|
||||
anchors to the card. */
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 14px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* No wrapping, no word-break: a diff is read by its indentation. */
|
||||
.line {
|
||||
min-height: var(--dsl-diff-line-height);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* A file header: the path in the primary tone, set apart by weight. The copy
|
||||
button floats over this first row's top-right corner, so reserve space at the
|
||||
line's end for it — a long path scrolls under the button otherwise, and the
|
||||
button's hit area would eat clicks on the path's tail. */
|
||||
.path {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 600;
|
||||
padding-right: 56px;
|
||||
}
|
||||
|
||||
/* A same-file second hunk's separator (a scattered edit), in the dim tone. */
|
||||
.gap {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The diff's own meaning-carrying colors: removed on the error token, added on
|
||||
the success token. A `- `/`+ ` prefix is drawn here so a copied line and the
|
||||
shown line agree, and so the sign reads without relying on color alone. */
|
||||
.del::before {
|
||||
content: '- ';
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.del {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.add::before {
|
||||
content: '+ ';
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.add {
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same
|
||||
footer the TUI transcript's diff card draws. */
|
||||
.footer {
|
||||
padding: 0 14px 12px;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
195
packages/client/ui-primitives/src/DiffBlock.tsx
Normal file
195
packages/client/ui-primitives/src/DiffBlock.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
|
||||
// control over one or more per-file hunks, each a bold path header followed by
|
||||
// the removed block (`-`, error color) and the added block (`+`, success
|
||||
// color), with a dim `└ +A -R · N file(s)` footer. Unlike the TUI's exact
|
||||
// changed-row comparison, this block renders the old and new sides in full.
|
||||
// Both front ends share the line-terminator rule and distinct-path file count.
|
||||
// Output never soft-wraps — an aligned source line keeps its indentation and
|
||||
// scrolls horizontally instead of folding. Colors resolve through --dsw-*
|
||||
// tokens; geometry mirrors CodeBlock.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import css from './DiffBlock.module.css'
|
||||
|
||||
/**
|
||||
* Output lines shown before the height cap collapses the middle. Matches
|
||||
* {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a
|
||||
* long body at the same place.
|
||||
*/
|
||||
export const DEFAULT_DIFF_MAX_LINES = 16
|
||||
|
||||
/**
|
||||
* One file's change, in the shape {@link DiffBlock} draws. Structurally the
|
||||
* render-intent contract's `FileDiff`, redeclared here so this primitive stays
|
||||
* free of the tool contract (the terminal card's decoupling, applied to diffs).
|
||||
*/
|
||||
export interface DiffHunk {
|
||||
/** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */
|
||||
path: string
|
||||
/** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
|
||||
oldText: string | null
|
||||
/** Content after the change (the added side). */
|
||||
newText: string
|
||||
}
|
||||
|
||||
export interface DiffBlockProps {
|
||||
/** One entry per applied hunk, in file order; empty renders nothing. */
|
||||
diffs: DiffHunk[]
|
||||
/** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/** A single rendered body line and its role, so the height cap slices a flat list. */
|
||||
interface DiffRow {
|
||||
kind: 'path' | 'del' | 'add' | 'gap'
|
||||
text: string
|
||||
}
|
||||
|
||||
/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */
|
||||
/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`unreachable diff row kind: ${String(value)}`)
|
||||
}
|
||||
|
||||
/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */
|
||||
const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
|
||||
path: css.path,
|
||||
del: css.del,
|
||||
add: css.add,
|
||||
gap: css.gap,
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten the hunks into the body's rows plus the footer counts. A path header
|
||||
* opens each new file; a same-file second hunk (a scattered edit) opens with a
|
||||
* `⋯` gap instead of repeating the path. Every old-side line counts toward
|
||||
* `removed` and every new-side line toward `added`. The file count is of
|
||||
* DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file
|
||||
* read as `1 file` on both front ends.
|
||||
* @param diffs - the hunks to render.
|
||||
* @returns the body rows, the +/- totals, and the distinct-file count.
|
||||
*/
|
||||
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
|
||||
const rows: DiffRow[] = []
|
||||
const paths = new Set<string>()
|
||||
let added = 0
|
||||
let removed = 0
|
||||
let prevPath: string | undefined
|
||||
for (const diff of diffs) {
|
||||
paths.add(diff.path)
|
||||
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
|
||||
else rows.push({ kind: 'gap', text: '⋯' })
|
||||
prevPath = diff.path
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of contentLines(diff.oldText)) {
|
||||
rows.push({ kind: 'del', text: line })
|
||||
removed++
|
||||
}
|
||||
}
|
||||
for (const line of contentLines(diff.newText)) {
|
||||
rows.push({ kind: 'add', text: line })
|
||||
added++
|
||||
}
|
||||
}
|
||||
return { rows, added, removed, files: paths.size }
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a side's text into its content lines. Empty text is zero lines (a full
|
||||
* deletion's `newText` or a create's absent `oldText` side draws nothing), and a
|
||||
* single trailing newline is a line terminator rather than an extra empty line —
|
||||
* the same terminator rule TerminalBlock applies to command output. An interior
|
||||
* blank line (a genuine `\n\n`) survives.
|
||||
* @param text - the removed or added side's text.
|
||||
* @returns the content lines, without the terminating newline.
|
||||
*/
|
||||
function contentLines(text: string): string[] {
|
||||
if (text === '') return []
|
||||
const body = text.endsWith('\n') ? text.slice(0, -1) : text
|
||||
return body.split('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its
|
||||
* content, exactly what the card shows. The removed and added blocks are the
|
||||
* change; the path headers keep a multi-file copy attributable.
|
||||
* @param rows - the flattened body rows.
|
||||
* @returns the diff as plain text.
|
||||
*/
|
||||
function copyText(rows: DiffRow[]): string {
|
||||
return rows.map((row) => {
|
||||
switch (row.kind) {
|
||||
case 'del': return `- ${row.text}`
|
||||
case 'add': return `+ ${row.text}`
|
||||
case 'path': return row.text
|
||||
case 'gap': return row.text
|
||||
/* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */
|
||||
default: return assertNever(row.kind)
|
||||
}
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a file mutation as an inline diff surface.
|
||||
* @param props - see {@link DiffBlockProps}.
|
||||
* @returns the diff block element.
|
||||
*/
|
||||
export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
|
||||
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
void writeClipboard(copyText(rows)).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, rows])
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const hidden = rows.length - maxLines
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
|
||||
// card, so a body's head and tail slices agree across the front ends.
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
const tailLines = maxLines - headLines
|
||||
const head = capped ? rows.slice(0, headLines) : rows
|
||||
const tail = capped ? rows.slice(rows.length - tailLines) : []
|
||||
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-diff="">
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
<div className={css.body}>
|
||||
{head.map((row, index) => (
|
||||
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
</button>
|
||||
)}
|
||||
{tail.map((row, index) => (
|
||||
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.footer}>└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
// Modal: controlled full-viewport dialog (create-workspace and similar).
|
||||
// Fixed overlay in the React tree (no react-dom portal) so ui-primitives
|
||||
// stays free of a react-dom dependency; mask tokens match figma 451:18655.
|
||||
// The overlay portals to this document's body so ancestor stacking contexts
|
||||
// cannot leave sticky page controls above the mask. This is still an in-page
|
||||
// WebUI dialog; it never creates or targets another browser/native window.
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseOutline16 } from './icons/index.tsx'
|
||||
import css from './Modal.module.css'
|
||||
@@ -17,6 +19,7 @@ import css from './Modal.module.css'
|
||||
* @param props.description - optional supporting sentence under the title.
|
||||
* @param props.children - body (inputs, etc.).
|
||||
* @param props.footer - action row (Cancel / Create).
|
||||
* @param props.contentClassName - optional class for a scrollable content region.
|
||||
* @param props.headless - render children directly in the card (no default
|
||||
* header/close/body chrome) for dialogs whose figma frame owns its own
|
||||
* header structure; mask, card, Escape, and aria-label remain.
|
||||
@@ -25,7 +28,7 @@ import css from './Modal.module.css'
|
||||
* @returns null when closed; otherwise the overlay tree.
|
||||
*/
|
||||
export function Modal({
|
||||
open, onClose, title, closeLabel = 'Close', description, children, footer, className, headless = false,
|
||||
open, onClose, title, closeLabel = 'Close', description, children, footer, className, contentClassName, headless = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -35,6 +38,7 @@ export function Modal({
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
headless?: boolean
|
||||
}) {
|
||||
useEffect(() => {
|
||||
@@ -48,7 +52,7 @@ export function Modal({
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
return createPortal((
|
||||
<div className={css.root} role="presentation">
|
||||
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
||||
<div
|
||||
@@ -61,7 +65,7 @@ export function Modal({
|
||||
? children
|
||||
: (
|
||||
<>
|
||||
<div className={css.content}>
|
||||
<div className={clsx(css.content, contentClassName)}>
|
||||
<div className={css.header}>
|
||||
<h2 className={css.title}>{title}</h2>
|
||||
<button type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
|
||||
@@ -78,5 +82,5 @@ export function Modal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
), document.body)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
.confirmation {
|
||||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.confirmationContent {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.confirmation {
|
||||
max-height: calc(100dvh - 48px);
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.warning p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
flex: none;
|
||||
margin-top: 2px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.acknowledgement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 3px 0 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.acknowledgement input:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.acknowledgement input:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modalAction {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.confirmAction {
|
||||
min-width: 136px;
|
||||
}
|
||||
80
packages/client/ui-primitives/src/RiskConfirmation.tsx
Normal file
80
packages/client/ui-primitives/src/RiskConfirmation.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Controlled risk acknowledgement dialog shared by product surfaces that
|
||||
* must gate a sensitive action behind an explicit checkbox.
|
||||
*/
|
||||
import { Button } from './Button.tsx'
|
||||
import { IconWarningOutline16 } from './icons/index.tsx'
|
||||
import { Modal } from './Modal.tsx'
|
||||
import css from './RiskConfirmation.module.css'
|
||||
|
||||
export interface RiskConfirmationProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
acknowledgeLabel: string
|
||||
cancelLabel: string
|
||||
confirmLabel: string
|
||||
acknowledged: boolean
|
||||
disabled?: boolean
|
||||
onAcknowledgedChange: (acknowledged: boolean) => void
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one in-page confirmation whose primary action is unavailable until
|
||||
* the caller-controlled acknowledgement is checked.
|
||||
*/
|
||||
export function RiskConfirmation({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
acknowledgeLabel,
|
||||
cancelLabel,
|
||||
confirmLabel,
|
||||
acknowledged,
|
||||
disabled = false,
|
||||
onAcknowledgedChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RiskConfirmationProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onCancel}
|
||||
title={title}
|
||||
className={css.confirmation ?? ''}
|
||||
contentClassName={css.confirmationContent ?? ''}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" className={css.modalAction} onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={css.confirmAction}
|
||||
disabled={disabled || !acknowledged}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className={css.warning}>
|
||||
<IconWarningOutline16 size={18} className={css.warningIcon} />
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label className={css.acknowledgement}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span>{acknowledgeLabel}</span>
|
||||
</label>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
|
||||
export { useAnchoredMaxHeight } from './useAnchoredMaxHeight.ts'
|
||||
export { HoverCard } from './HoverCard.tsx'
|
||||
export { Modal } from './Modal.tsx'
|
||||
export { RiskConfirmation } from './RiskConfirmation.tsx'
|
||||
export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
|
||||
export { ConnectionBanner } from './ConnectionBanner.tsx'
|
||||
export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
@@ -22,6 +24,8 @@ export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
|
||||
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
|
||||
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
|
||||
export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx'
|
||||
export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
|
||||
Reference in New Issue
Block a user