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', {
|
||||||
@@ -531,6 +575,6 @@ describe('small branch tails', () => {
|
|||||||
: undefined}
|
: undefined}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
expect(view.container.textContent).toBe('1 turns · 1 steps|Input 0 tok · Output 10 tok')
|
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 10 tok')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
const el = bubble.current
|
if (pos === null) return
|
||||||
if (el === null || pos === null) return
|
const clamp = () => {
|
||||||
const EDGE_MARGIN = 12
|
const el = bubble.current
|
||||||
const r = el.getBoundingClientRect()
|
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
||||||
let dx = 0
|
if (el === null) return
|
||||||
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
const EDGE_MARGIN = 12
|
||||||
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
el.style.left = `${pos.x}px`
|
||||||
if (dx !== 0) el.style.left = `${pos.x + dx}px`
|
const r = el.getBoundingClientRect()
|
||||||
}, [pos])
|
let dx = 0
|
||||||
|
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
|
||||||
|
el.style.left = `${pos.x + dx}px`
|
||||||
|
}
|
||||||
|
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,23 +726,16 @@ 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"
|
/>
|
||||||
/>
|
<path d="M7.5 8.62109L9.12109 7" stroke="currentColor" strokeWidth="1.3" />
|
||||||
<path d="M7.5 8.62109L9.12109 7" stroke="currentColor" strokeWidth="1.3" />
|
<path
|
||||||
<path
|
d="M9.08245 3.35798L11.8651 0.575334C11.895 0.545384 11.9463 0.56391 11.9502 0.606086L12.2362 3.69859C12.2384 3.72259 12.2574 3.74159 12.2814 3.74378L15.3697 4.02583C15.4119 4.02968 15.4305 4.08101 15.4005 4.11098L12.618 6.89351C12.6086 6.90289 12.5959 6.90816 12.5826 6.90816L9.11781 6.90815C9.09019 6.90816 9.06781 6.88577 9.06781 6.85816L9.06781 3.39333C9.06781 3.38007 9.07308 3.36735 9.08245 3.35798Z"
|
||||||
d="M9.08245 3.35798L11.8651 0.575334C11.895 0.545384 11.9463 0.56391 11.9502 0.606086L12.2362 3.69859C12.2384 3.72259 12.2574 3.74159 12.2814 3.74378L15.3697 4.02583C15.4119 4.02968 15.4305 4.08101 15.4005 4.11098L12.618 6.89351C12.6086 6.90289 12.5959 6.90816 12.5826 6.90816L9.11781 6.90815C9.09019 6.90816 9.06781 6.88577 9.06781 6.85816L9.06781 3.39333C9.06781 3.38007 9.07308 3.36735 9.08245 3.35798Z"
|
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