fix(web): address UI polish review feedback
This commit is contained in:
@@ -58,7 +58,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
|
|||||||
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
|
await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
|
||||||
// The dormant pi-ai adapter contributes its whole installed catalog; no
|
// The dormant pi-ai adapter contributes its whole installed catalog; no
|
||||||
// provider is configured yet, so the page is one add button.
|
// provider is configured yet, so the page is one add button.
|
||||||
const add = dialog.getByRole('button', { name: '+ 添加提供方' })
|
const add = dialog.getByRole('button', { name: '添加提供方' })
|
||||||
await add.waitFor({ timeout: 10_000 })
|
await add.waitFor({ timeout: 10_000 })
|
||||||
// The button enables once the dormant catalog lands in the join.
|
// The button enables once the dormant catalog lands in the join.
|
||||||
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||||
|
|||||||
@@ -17,4 +17,6 @@
|
|||||||
- text: minimax-cn
|
- text: minimax-cn
|
||||||
- button "编辑"
|
- button "编辑"
|
||||||
- button "删除"
|
- button "删除"
|
||||||
- button "+ 添加提供方"
|
- button "添加提供方":
|
||||||
|
- img
|
||||||
|
- text: 添加提供方
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
- textbox "Edit queued message": Edited queue item
|
- textbox "Edit queued message": Edited queue item
|
||||||
- button "Save queued message":
|
- button "Save queued message":
|
||||||
- img
|
- img
|
||||||
|
- tooltip "Save queued message"
|
||||||
- button "Cancel editing":
|
- button "Cancel editing":
|
||||||
- img
|
- img
|
||||||
- textbox "Message the agent"
|
- textbox "Message the agent"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
- paragraph: partial
|
- paragraph: partial
|
||||||
- status: Deep diving...
|
- status: Deep diving...
|
||||||
- region "To-dos":
|
- region "To-dos":
|
||||||
- button "To-dos 1/2 tasks · 1 in progress"
|
- button "To-dos 1 completed · 1 in progress"
|
||||||
- img
|
- img
|
||||||
- text: Ongoing Goal Keep the composer context panels aligned
|
- text: Ongoing Goal Keep the composer context panels aligned
|
||||||
- button "Pause goal":
|
- button "Pause goal":
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
- text: Edited queue item
|
- text: Edited queue item
|
||||||
- button "Edit queued message":
|
- button "Edit queued message":
|
||||||
- img
|
- img
|
||||||
|
- tooltip "Edit queued message"
|
||||||
- button "Remove queued message":
|
- button "Remove queued message":
|
||||||
- img
|
- img
|
||||||
- button "Steer queued message":
|
- button "Steer queued message":
|
||||||
|
|||||||
@@ -54,9 +54,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
/* Grows (detail stays right-aligned) but never shrinks: when the row runs
|
flex: 1 1 auto;
|
||||||
out of width the detail alone truncates, keeping the name readable. */
|
min-width: 0;
|
||||||
flex: 1 0 auto;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Shared IconActions chrome for user, steering, and assistant messages: copy
|
// Shared IconActions chrome for user, steering, and assistant messages: copy
|
||||||
// live, optional branch wiring, and an optional date-aware clock.
|
// live, optional branch wiring, and an optional date-aware clock.
|
||||||
|
|
||||||
import { useCallback, useId, useState } from 'react'
|
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip,
|
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
import { formatMessageClock } from './message-chrome.ts'
|
||||||
import { useCalendarDay } from './use-calendar-day.ts'
|
import { useCalendarDay } from './use-calendar-day.ts'
|
||||||
import css from './MessageIconActions.module.css'
|
import css from './MessageIconActions.module.css'
|
||||||
|
|
||||||
@@ -42,11 +42,27 @@ export function MessageIconActions({
|
|||||||
// Same success chrome as CodeBlock: a short check swap after the write,
|
// Same success chrome as CodeBlock: a short check swap after the write,
|
||||||
// gated so re-clicks during the window neither re-copy nor stack timers.
|
// gated so re-clicks during the window neither re-copy nor stack timers.
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const copyPending = useRef(false)
|
||||||
|
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const copyEpoch = useRef(0)
|
||||||
|
useEffect(() => () => {
|
||||||
|
copyEpoch.current += 1
|
||||||
|
copyPending.current = false
|
||||||
|
if (copyTimer.current !== null) clearTimeout(copyTimer.current)
|
||||||
|
}, [])
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCallback(() => {
|
||||||
if (copied) return
|
if (copied || copyPending.current) return
|
||||||
void writeClipboard(text).then(() => {
|
const epoch = copyEpoch.current
|
||||||
|
copyPending.current = true
|
||||||
|
void writeClipboard(text).then((ok) => {
|
||||||
|
if (epoch !== copyEpoch.current) return
|
||||||
|
copyPending.current = false
|
||||||
|
if (!ok) return
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
copyTimer.current = window.setTimeout(() => {
|
||||||
|
copyTimer.current = null
|
||||||
|
setCopied(false)
|
||||||
|
}, 1000)
|
||||||
})
|
})
|
||||||
}, [copied, text])
|
}, [copied, text])
|
||||||
const clockEl = time === undefined ? null : (
|
const clockEl = time === undefined ? null : (
|
||||||
|
|||||||
@@ -6,45 +6,6 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
|||||||
/** The date-template share of the conversation dictionary the clock consumes. */
|
/** The date-template share of the conversation dictionary the clock consumes. */
|
||||||
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
|
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
|
|
||||||
* @param text - Plain text to place on the clipboard.
|
|
||||||
*/
|
|
||||||
export async function writeClipboard(text: string): Promise<void> {
|
|
||||||
// lib.dom types clipboard non-optional, but insecure contexts omit it —
|
|
||||||
// that runtime gap is exactly what this guard detects.
|
|
||||||
/* oxlint-disable-next-line typescript/no-unnecessary-condition */
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
} catch {
|
|
||||||
// Denied permissions / iframe policy.
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// execCommand('copy') is the only clipboard fallback where the async API
|
|
||||||
// is missing (insecure contexts); deprecated but deliberately retained.
|
|
||||||
/* oxlint-disable typescript/no-deprecated */
|
|
||||||
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.
|
|
||||||
}
|
|
||||||
/* oxlint-enable typescript/no-deprecated */
|
|
||||||
el.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
return String(n).padStart(2, '0')
|
return String(n).padStart(2, '0')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,18 @@
|
|||||||
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
|
/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the
|
||||||
viewport bottom inside the centered message column; textarea on top, action
|
viewport bottom inside the centered message column; textarea on top, action
|
||||||
row below, one primary circle button bottom-right. Input width rides the
|
row below, one primary circle button bottom-right. Input width rides the
|
||||||
column (--dsh-composer-card-max-width = chat content + 16px is a cap, not a
|
column (--dsh-composer-card-max-width = chat content + 32px, 16px per side,
|
||||||
fixed size — layout rule: the box shrinks with the center column keeping
|
is a cap, not a fixed size — layout rule: the box shrinks with the center
|
||||||
its clearance). Hero variant = the same card centered in the empty state;
|
column keeping its clearance). Hero variant = the same card centered in the
|
||||||
the transition between the two is a position move of one component. */
|
empty state; the transition between the two is a position move of one
|
||||||
|
component. */
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
|
/* Side pads ride the shared clearance (figma Input_Bottom drew L32/R32/B8;
|
||||||
the sides narrowed with the chat+16 width axis); the bottom gradient mask
|
the sides narrow with the shared width axis); the bottom gradient mask
|
||||||
is owned by the chat scroller. No top pad: the composer stack's gap owns
|
is owned by the chat scroller. No top pad: the composer stack's gap owns
|
||||||
the space above; error/status strips still carry their own margin. */
|
the space above; error/status strips still carry their own margin. */
|
||||||
padding: 0 var(--dsh-composer-side-clearance) 8px;
|
padding: 0 var(--dsh-composer-side-clearance) 8px;
|
||||||
|
|||||||
@@ -102,16 +102,10 @@ describe('MessageItem arms', () => {
|
|||||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('user copy stays quiet when execCommand throws or is absent', () => {
|
it('user copy never claims success when the host rejects the write', async () => {
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: undefined,
|
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||||
})
|
|
||||||
Object.defineProperty(document, 'execCommand', {
|
|
||||||
configurable: true,
|
|
||||||
value: () => {
|
|
||||||
throw new Error('denied')
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
render(
|
render(
|
||||||
<MessageItem t={t} node={{
|
<MessageItem t={t} node={{
|
||||||
@@ -122,12 +116,12 @@ describe('MessageItem arms', () => {
|
|||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||||
|
await act(async () => {
|
||||||
Object.defineProperty(document, 'execCommand', {
|
await Promise.resolve()
|
||||||
configurable: true,
|
await Promise.resolve()
|
||||||
value: undefined,
|
|
||||||
})
|
})
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||||
|
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => {
|
it('copy swaps to the check success chrome, gates re-clicks, and reverts after a second', async () => {
|
||||||
@@ -145,7 +139,10 @@ describe('MessageItem arms', () => {
|
|||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
const copy = screen.getByRole('button', { name: '复制' })
|
||||||
|
fireEvent.click(copy)
|
||||||
|
fireEvent.click(copy)
|
||||||
|
expect(writeText).toHaveBeenCalledTimes(1)
|
||||||
// Two microtask ticks: writeClipboard's own await, then the .then that
|
// Two microtask ticks: writeClipboard's own await, then the .then that
|
||||||
// lands the success chrome.
|
// lands the success chrome.
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -159,6 +156,53 @@ describe('MessageItem arms', () => {
|
|||||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears copy feedback work when the message unmounts', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
let finishWrite!: () => void
|
||||||
|
const writeText = vi.fn(() => new Promise<void>((resolve) => { finishWrite = resolve }))
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText },
|
||||||
|
})
|
||||||
|
const view = render(
|
||||||
|
<MessageItem t={t} node={{
|
||||||
|
kind: 'user', seq: 1, time: 1_000,
|
||||||
|
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||||
|
source: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||||
|
view.unmount()
|
||||||
|
await act(async () => {
|
||||||
|
finishWrite()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
|
||||||
|
const mounted = render(
|
||||||
|
<MessageItem t={t} node={{
|
||||||
|
kind: 'user', seq: 2, time: 1_000,
|
||||||
|
content: [{ type: 'text', text: 'copied body' }] as never,
|
||||||
|
source: null,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
configurable: true,
|
||||||
|
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||||
|
mounted.unmount()
|
||||||
|
expect(vi.getTimerCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('consumed steering renders copy and branch actions without a badge', () => {
|
it('consumed steering renders copy and branch actions without a badge', () => {
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
|
|||||||
@@ -143,7 +143,6 @@ function bench(over?: BenchOptions) {
|
|||||||
}
|
}
|
||||||
const view = render(<InputBar {...props} />)
|
const view = render(<InputBar {...props} />)
|
||||||
const textarea = view.container.querySelector('textarea')!
|
const textarea = view.container.querySelector('textarea')!
|
||||||
// aria-label (not role name): title carries the same label and would double-match.
|
|
||||||
const stopping = over?.running === true && over.subagent === undefined
|
const stopping = over?.running === true && over.subagent === undefined
|
||||||
const button = view.container.querySelector<HTMLButtonElement>(
|
const button = view.container.querySelector<HTMLButtonElement>(
|
||||||
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
|
`button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
background: var(--dsw-specific-tip);
|
background: var(--dsw-specific-tip);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sparkle {
|
.goalGlyph {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
|
|||||||
return (
|
return (
|
||||||
<div className={css.dock} data-goal-bar>
|
<div className={css.dock} data-goal-bar>
|
||||||
<div className={css.bar} title={title}>
|
<div className={css.bar} title={title}>
|
||||||
<span className={css.sparkle}><IconGoalOutline16 size={14} /></span>
|
<span className={css.goalGlyph}><IconGoalOutline16 size={14} /></span>
|
||||||
<span className={css.label}>{t(PHASE_LABELS[goal.phase])}</span>
|
<span className={css.label}>{t(PHASE_LABELS[goal.phase])}</span>
|
||||||
<span className={css.objective}>{goal.objective}</span>
|
<span className={css.objective}>{goal.objective}</span>
|
||||||
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
|
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('GoalBar', () => {
|
|||||||
expect(complete.container.firstChild).toBeNull()
|
expect(complete.container.firstChild).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('active goal: sparkle, "进行中的目标", truncated objective, edit and clear actions', () => {
|
it('active goal: goal glyph, "进行中的目标", truncated objective, edit and clear actions', () => {
|
||||||
const actions = makeActions()
|
const actions = makeActions()
|
||||||
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
|
||||||
expect(screen.getByText('进行中的目标')).toBeTruthy()
|
expect(screen.getByText('进行中的目标')).toBeTruthy()
|
||||||
|
|||||||
@@ -277,6 +277,8 @@
|
|||||||
.selectInput {
|
.selectInput {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
padding-right: 32px;
|
padding-right: 32px;
|
||||||
|
/* Data-URI SVGs cannot resolve CSS variables; #81858C is the caption gray
|
||||||
|
shared by both themes. */
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-position: right 12px center;
|
background-position: right 12px center;
|
||||||
|
|||||||
@@ -47,19 +47,27 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
|||||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
const bubble = useRef<HTMLSpanElement | null>(null)
|
const bubble = useRef<HTMLSpanElement | null>(null)
|
||||||
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
||||||
// a centered bubble near the right edge would clip. Measured after paint and
|
// a centered bubble near the right edge would clip. Each measurement resets
|
||||||
// written straight to the style (no state), so it cannot re-trigger itself.
|
// the base position before applying a direct style offset, allowing a shorter
|
||||||
// EDGE_MARGIN keeps the bubble 12px off the viewport edges instead of flush.
|
// label or wider viewport to release a previous clamp without another render.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (pos === null) return
|
||||||
|
const clamp = () => {
|
||||||
const el = bubble.current
|
const el = bubble.current
|
||||||
if (el === null || pos === null) return
|
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
||||||
|
if (el === null) return
|
||||||
const EDGE_MARGIN = 12
|
const EDGE_MARGIN = 12
|
||||||
|
el.style.left = `${pos.x}px`
|
||||||
const r = el.getBoundingClientRect()
|
const r = el.getBoundingClientRect()
|
||||||
let dx = 0
|
let dx = 0
|
||||||
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
||||||
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
||||||
if (dx !== 0) el.style.left = `${pos.x + dx}px`
|
el.style.left = `${pos.x + dx}px`
|
||||||
}, [pos])
|
}
|
||||||
|
clamp()
|
||||||
|
window.addEventListener('resize', clamp)
|
||||||
|
return () => { window.removeEventListener('resize', clamp) }
|
||||||
|
}, [label, pos])
|
||||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
// Hover and focus are independent triggers: the bubble hides only after
|
// Hover and focus are independent triggers: the bubble hides only after
|
||||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Package-internal clipboard write, shared by every copy control in this
|
// Host clipboard write shared by Web UI copy controls. Success feedback stays
|
||||||
// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
|
// with each control; this seam only reports whether the host accepted a write.
|
||||||
// public surface: consumers get the components, not the host detection.
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write text to the host clipboard, preferring the async Clipboard API and
|
* Write text to the host clipboard, preferring the async Clipboard API and
|
||||||
|
|||||||
@@ -726,7 +726,6 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
|
|||||||
/** ic_ds_goal_outline_16 (goal strip leading glyph: dartboard with a landed arrow) */
|
/** ic_ds_goal_outline_16 (goal strip leading glyph: dartboard with a landed arrow) */
|
||||||
export const IconGoalOutline16 = ({ size = 16, className }: IconProps) => (
|
export const IconGoalOutline16 = ({ size = 16, className }: IconProps) => (
|
||||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g clipPath="url(#clip0_1498_30899)">
|
|
||||||
<path
|
<path
|
||||||
d="M8 0C8.31451 0 8.62464 0.019379 8.92969 0.0546875C8.48228 0.403371 8.0952 0.825758 7.78809 1.30469C4.18586 1.41664 1.2998 4.37061 1.2998 8C1.2998 11.7003 4.29969 14.7002 8 14.7002C11.6297 14.7002 14.5829 11.8136 14.6943 8.21094C15.1734 7.90377 15.5956 7.51688 15.9443 7.06934C15.9797 7.37473 16 7.68512 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0ZM7.0166 3.6084C7.00658 3.73765 7 3.86817 7 4C7 4.31845 7.03098 4.62973 7.08789 4.93164C5.76489 5.32438 4.7998 6.54958 4.7998 8C4.7998 9.76731 6.23269 11.2002 8 11.2002C9.45065 11.2002 10.6749 10.2345 11.0674 8.91113C11.3696 8.96818 11.6812 9 12 9C12.1315 9 12.2617 8.99239 12.3906 8.98242C11.9423 10.995 10.1477 12.5 8 12.5C5.51472 12.5 3.5 10.4853 3.5 8C3.5 5.85255 5.00435 4.05702 7.0166 3.6084Z"
|
d="M8 0C8.31451 0 8.62464 0.019379 8.92969 0.0546875C8.48228 0.403371 8.0952 0.825758 7.78809 1.30469C4.18586 1.41664 1.2998 4.37061 1.2998 8C1.2998 11.7003 4.29969 14.7002 8 14.7002C11.6297 14.7002 14.5829 11.8136 14.6943 8.21094C15.1734 7.90377 15.5956 7.51688 15.9443 7.06934C15.9797 7.37473 16 7.68512 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0ZM7.0166 3.6084C7.00658 3.73765 7 3.86817 7 4C7 4.31845 7.03098 4.62973 7.08789 4.93164C5.76489 5.32438 4.7998 6.54958 4.7998 8C4.7998 9.76731 6.23269 11.2002 8 11.2002C9.45065 11.2002 10.6749 10.2345 11.0674 8.91113C11.3696 8.96818 11.6812 9 12 9C12.1315 9 12.2617 8.99239 12.3906 8.98242C11.9423 10.995 10.1477 12.5 8 12.5C5.51472 12.5 3.5 10.4853 3.5 8C3.5 5.85255 5.00435 4.05702 7.0166 3.6084Z"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
@@ -737,12 +736,6 @@ export const IconGoalOutline16 = ({ size = 16, className }: IconProps) => (
|
|||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
strokeWidth="1.3"
|
strokeWidth="1.3"
|
||||||
/>
|
/>
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="clip0_1498_30899">
|
|
||||||
<rect width={16} height={16} fill="currentColor" />
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export { FishLogo } from './FishLogo.tsx'
|
|||||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||||
export { Tooltip } from './Tooltip.tsx'
|
export { Tooltip } from './Tooltip.tsx'
|
||||||
export type { TooltipSide } from './Tooltip.tsx'
|
export type { TooltipSide } from './Tooltip.tsx'
|
||||||
|
export { writeClipboard } from './clipboard.ts'
|
||||||
export { JsonTree } from './JsonTree.tsx'
|
export { JsonTree } from './JsonTree.tsx'
|
||||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import { cleanup, render } from '@testing-library/react'
|
import { cleanup, render } from '@testing-library/react'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import { IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconSendOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
import {
|
||||||
|
IconApiOutline14, IconArchiveOutline20, IconFolderClose16, IconGoalOutline16, IconSendOutline16,
|
||||||
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
|
|
||||||
afterEach(cleanup)
|
afterEach(cleanup)
|
||||||
|
|
||||||
@@ -44,6 +46,12 @@ describe('ic_ds_ icon set', () => {
|
|||||||
const archive = render(<IconArchiveOutline20 />)
|
const archive = render(<IconArchiveOutline20 />)
|
||||||
expect(archive.container.querySelector('svg')!.getAttribute('width')).toBe('20')
|
expect(archive.container.querySelector('svg')!.getAttribute('width')).toBe('20')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders reusable goal glyphs without document-global ids', () => {
|
||||||
|
const { container } = render(<><IconGoalOutline16 /><IconGoalOutline16 /></>)
|
||||||
|
expect(container.querySelector('[id]')).toBeNull()
|
||||||
|
expect(container.querySelector('[clip-path]')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('FishLogo', () => {
|
describe('FishLogo', () => {
|
||||||
|
|||||||
@@ -92,6 +92,37 @@ describe('Tooltip', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reclamps after label and viewport width changes', () => {
|
||||||
|
const originalWidth = window.innerWidth
|
||||||
|
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||||
|
if (this.getAttribute('role') !== 'tooltip') return rect(900, 1000)
|
||||||
|
return this.textContent === 'Wide' ? rect(900, 1100) : rect(850, 950)
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const view = render(
|
||||||
|
<Tooltip label="Wide" side="bottom">
|
||||||
|
<button type="button">anchor</button>
|
||||||
|
</Tooltip>,
|
||||||
|
)
|
||||||
|
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||||
|
expect(screen.getByRole('tooltip').style.left).toBe('862px')
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<Tooltip label="Short" side="bottom">
|
||||||
|
<button type="button">anchor</button>
|
||||||
|
</Tooltip>,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('tooltip').style.left).toBe('950px')
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 900 })
|
||||||
|
fireEvent(window, new Event('resize'))
|
||||||
|
expect(screen.getByRole('tooltip').style.left).toBe('888px')
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalWidth })
|
||||||
|
spy.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('clamps a bubble past the left viewport edge back inside', () => {
|
it('clamps a bubble past the left viewport edge back inside', () => {
|
||||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
|
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -108,10 +108,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.frame {
|
|
||||||
padding: 6px 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,10 +367,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.frame {
|
|
||||||
padding: 6px 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user