Merge remote-tracking branch 'origin/master' into codex/session-pending-interactions
# Conflicts: # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow),
|
||||
except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling:
|
||||
tooltip-bg plate,
|
||||
except padding tightened 6/12 -> 3/7, type 14/22 -> 13/20, and radius
|
||||
10 -> 8 by product ruling: tooltip-bg plate,
|
||||
one text color across both themes (the plate stays dark in light and dark
|
||||
mode). Behavior (fixed positioning off the anchor rect) is local — the
|
||||
upstream Floating stack is intentionally not vendored. */
|
||||
@@ -8,13 +8,20 @@
|
||||
.bubble {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
padding: 4px 8px;
|
||||
/* Fixed-position shrink-to-fit measures only the space from `left` to the
|
||||
viewport edge, so anchors near the right edge would wrap early;
|
||||
max-content sizes by the label alone, capped at half the viewport. */
|
||||
width: max-content;
|
||||
max-width: 50vw;
|
||||
padding: 3px 7px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-tooltip-bg);
|
||||
color: var(--dsw-static-neutral-bluish-00);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
white-space: pre-line;
|
||||
/* Unbreakable tokens (URLs, paths) must not push past max-width. */
|
||||
overflow-wrap: break-word;
|
||||
pointer-events: none;
|
||||
animation: tooltip-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -27,6 +34,10 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.bubble[data-side='top'] {
|
||||
transform: translate(-50%, -100%);
|
||||
}
|
||||
|
||||
@keyframes tooltip-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||
// TODO: interaction is a placeholder (no flip on viewport collision or
|
||||
// arrow) — visuals and behavior get a proper pass later.
|
||||
// TODO: interaction is a placeholder (horizontal overflow clamps, but there
|
||||
// is no vertical flip on viewport collision and no arrow) — visuals and
|
||||
// behavior get a proper pass later.
|
||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
||||
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
||||
// position:fixed and coordinates come from the anchor's rect at show time, so
|
||||
// it escapes ancestor overflow clipping (the sidebar rail clips its column)
|
||||
// without a portal.
|
||||
|
||||
import { cloneElement, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { cloneElement, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
|
||||
import css from './Tooltip.module.css'
|
||||
|
||||
/** Bubble placement relative to the anchor. */
|
||||
export type TooltipSide = 'right' | 'bottom'
|
||||
export type TooltipSide = 'right' | 'bottom' | 'top'
|
||||
|
||||
/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */
|
||||
interface AnchorProps {
|
||||
@@ -44,6 +45,29 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
||||
}, [childRef])
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const bubble = useRef<HTMLSpanElement | null>(null)
|
||||
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
||||
// a centered bubble near the right edge would clip. Each measurement resets
|
||||
// the base position before applying a direct style offset, allowing a shorter
|
||||
// label or wider viewport to release a previous clamp without another render.
|
||||
useLayoutEffect(() => {
|
||||
if (pos === null) return
|
||||
const clamp = () => {
|
||||
const el = bubble.current
|
||||
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
||||
if (el === null) return
|
||||
const EDGE_MARGIN = 12
|
||||
el.style.left = `${pos.x}px`
|
||||
const r = el.getBoundingClientRect()
|
||||
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)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
@@ -73,7 +97,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
const r = el.getBoundingClientRect()
|
||||
setPos(side === 'right'
|
||||
? { x: r.right + 10, y: r.top + r.height / 2 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
: side === 'top'
|
||||
? { x: r.left + r.width / 2, y: r.top - 8 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
}
|
||||
const showAfterHoverDelay = () => {
|
||||
cancelShow()
|
||||
@@ -101,7 +127,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
<span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Package-internal clipboard write, shared by every copy control in this
|
||||
// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
|
||||
// public surface: consumers get the components, not the host detection.
|
||||
// Host clipboard write shared by Web UI copy controls. Success feedback stays
|
||||
// with each control; this seam only reports whether the host accepted a write.
|
||||
|
||||
/**
|
||||
* Write text to the host clipboard, preferring the async Clipboard API and
|
||||
|
||||
@@ -675,6 +675,26 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */
|
||||
export const IconSendOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7.24707 1.01771C7.52897 1.07653 7.77619 1.19694 8.00391 1.38001C8.19202 1.53136 8.39884 1.73784 8.61914 1.95814L12.6396 5.9806L11.6299 6.99134L7.71484 3.0763V13.0001H6.28516V3.0763L2.36914 6.99134L1.35938 5.9806L5.38086 1.95814C5.60116 1.73784 5.80798 1.53136 5.99609 1.38001C6.19476 1.22027 6.4385 1.06739 6.75195 1.01771C6.91296 0.992304 7.07471 0.997504 7.24707 1.01771Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_queue_outline_14 (figma extract): open chat bubble with two queued lines. */
|
||||
export const IconQueueOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7.00049 0.199829C3.24488 0.199829 0.199952 3.24408 0.199707 6.99963C0.199707 8.0414 0.434087 9.03061 0.854004 9.91467L1.11279 10.4576L2.19775 9.94202L1.94092 9.39905L1.81787 9.12268C1.5498 8.46885 1.40186 7.75171 1.40186 6.99963C1.4021 3.90808 3.90888 1.40198 7.00049 1.40198C10.0919 1.40219 12.5979 3.90821 12.5981 6.99963C12.5981 10.0913 10.0921 12.5981 7.00049 12.5983C6.36734 12.5983 5.90348 12.5535 5.49268 12.4401C5.08803 12.3283 4.7041 12.1414 4.24463 11.8209C3.57111 11.3511 2.60588 11.1855 1.81006 11.6881L1.79736 11.6959L1.78467 11.7047L1.25537 12.0778L1.65381 13.2672L2.46045 12.6989C2.75029 12.5214 3.18004 12.5442 3.55615 12.8063C4.10063 13.1861 4.60863 13.4423 5.17334 13.5983C5.73194 13.7525 6.31665 13.8004 7.00049 13.8004C10.7561 13.8002 13.8003 10.7553 13.8003 6.99963C13.8 3.24421 10.7559 0.200041 7.00049 0.199829ZM3.81201 7.47327V8.67542H7.11572V7.47327H3.81201ZM3.81201 6.34924H10.2173V5.14709H3.81201V6.34924Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_checklist_outline_14 (figma extract): two rings + two list bars. */
|
||||
export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -703,7 +723,23 @@ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** sparkle_16 (Others tool-row / goal strip leading glyph; hand-authored three-star
|
||||
/** ic_ds_goal_outline_16 (goal strip leading glyph: dartboard with a landed arrow) */
|
||||
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">
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path d="M7.5 8.62109L9.12109 7" stroke="currentColor" strokeWidth="1.3" />
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** sparkle_16 (Others tool-row leading glyph; hand-authored three-star
|
||||
* approximation — the figma 43:31850 glyph is an SF Symbols "sparkles" text glyph,
|
||||
* not extractable as vector data) */
|
||||
export const IconSparkle16 = ({ size = 16, className }: IconProps) => (
|
||||
|
||||
@@ -20,6 +20,7 @@ export { FishLogo } from './FishLogo.tsx'
|
||||
export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { writeClipboard } from './clipboard.ts'
|
||||
export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
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)
|
||||
|
||||
@@ -14,8 +16,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full P-I set (45 deepsuite + 15 figma extracts + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(61)
|
||||
it('exports the full P-I set (46 deepsuite + 17 figma extracts + the hand-authored sparkle)', () => {
|
||||
expect(iconNames.length).toBe(64)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
@@ -44,6 +46,12 @@ describe('ic_ds_ icon set', () => {
|
||||
const archive = render(<IconArchiveOutline20 />)
|
||||
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', () => {
|
||||
|
||||
@@ -43,8 +43,9 @@ describe('Tooltip', () => {
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.textContent).toBe('Open sidebar')
|
||||
expect(bubble.getAttribute('data-side')).toBe('right')
|
||||
// jsdom rects are all-zero: right placement lands at the +10 gutter.
|
||||
expect(bubble.style.left).toBe('10px')
|
||||
// jsdom rects are all-zero: right placement lands at the +10 gutter, then
|
||||
// the zero-width measured rect clamps to the 12px edge margin (10 + 12).
|
||||
expect(bubble.style.left).toBe('22px')
|
||||
expect(bubble.style.top).toBe('0px')
|
||||
fireEvent.mouseLeave(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
@@ -60,12 +61,100 @@ describe('Tooltip', () => {
|
||||
fireEvent.focus(anchor)
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('bottom')
|
||||
expect(bubble.style.left).toBe('0px')
|
||||
// Zero-width jsdom rect at x=0 clamps to the 12px edge margin.
|
||||
expect(bubble.style.left).toBe('12px')
|
||||
expect(bubble.style.top).toBe('8px')
|
||||
fireEvent.blur(anchor)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
// jsdom's default rects are all-zero, so the clamp tests stub the measured
|
||||
// rect (anchor and bubble share the prototype stub) and derive expectations
|
||||
// from it: pos.x = anchor center, then shifted by the measured overflow.
|
||||
const rect = (left: number, right: number): DOMRect =>
|
||||
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
|
||||
|
||||
it('clamps a bubble overflowing the right viewport edge back inside', () => {
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Wide" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
// pos.x = 1000 (anchor center); measured right edge 1100 overflows the
|
||||
// 1024 viewport's 12px safe margin (limit 1012) by 88, so the clamp
|
||||
// shifts left to 912.
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('912px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(-20, 80))
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Wide" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
// pos.x = 30 (anchor center); measured left edge -20 underflows the
|
||||
// 12px safe margin by 32, so the clamp shifts right to 62.
|
||||
expect(screen.getByRole('tooltip').style.left).toBe('62px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('supports top placement for anchors at the viewport bottom', () => {
|
||||
render(
|
||||
<Tooltip label="Above" side="top">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('top')
|
||||
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
|
||||
// zero-width measured rect clamps left to the 12px edge margin.
|
||||
expect(bubble.style.left).toBe('12px')
|
||||
expect(bubble.style.top).toBe('-8px')
|
||||
})
|
||||
|
||||
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
|
||||
const onMouseEnter = vi.fn()
|
||||
const onMouseLeave = vi.fn()
|
||||
|
||||
Reference in New Issue
Block a user