fix(gui): address review — stale smoke case, tooltip trigger overlap, collapse contract docs

- The real-browser collapse smoke tracked the old chrome: visible HARNESS
  text (the wordmark svg is aria-hidden now), an 'Expand sidebar' label
  (renamed 'Open sidebar'), a 300px settle (default is 280), and an
  immediate focus assert (rail search defers focus past the slide). The
  case now tracks the brand span, polls the deferred focus, and uses the
  current labels and width.
- Tooltip treated hover and focus as one trigger: leaving with the mouse
  dropped the bubble of a still-focused anchor (and vice versa). The two
  triggers are tracked independently; the bubble hides only after both
  clear. Spec pins both orders.
- The ui-sidebar README and the bilingual collapse note still described
  the retired geometry morph; both now state the slide + crossfade
  contract, the fixed-width (never-conceding) sidebar, and the rail's
  whale-mark/tooltip chrome.
This commit is contained in:
imccyu
2026-07-24 01:30:01 +08:00
parent 9dbe11d647
commit 75d37654cb
8 changed files with 53 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.

View File

@@ -34,11 +34,14 @@ interface AnchorProps {
export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
const [pos, setPos] = useState<{ x: number; y: number } | 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).
const triggers = useRef({ hover: false, focus: false })
// Disabling mid-hover (e.g. clicking a rail control expands the sidebar)
// must drop an already-visible bubble: no mouseleave fires.
useEffect(() => {
if (disabled) setPos(null)
if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) }
}, [disabled])
const show = () => {
@@ -51,16 +54,18 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
? { x: r.right + 10, y: r.top + r.height / 2 }
: { x: r.left + r.width / 2, y: r.bottom + 8 })
}
const hide = () => { setPos(null) }
const hide = () => {
if (!triggers.current.hover && !triggers.current.focus) setPos(null)
}
return (
<>
{cloneElement(children, {
ref: anchor,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); hide() },
onFocus: (e) => { children.props.onFocus?.(e); show() },
onBlur: (e) => { children.props.onBlur?.(e); hide() },
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
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">

View File

@@ -81,6 +81,29 @@ describe('Tooltip', () => {
expect(screen.getByRole('tooltip')).toBeTruthy()
})
it('keeps the bubble while either hover or focus is still active', () => {
render(
<Tooltip label="Sticky">
<button type="button">anchor</button>
</Tooltip>,
)
const anchor = screen.getByText('anchor')
// Focused AND hovered: leaving with the mouse must not drop the bubble.
fireEvent.focus(anchor)
fireEvent.mouseEnter(anchor)
fireEvent.mouseLeave(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
// Symmetric: blurring while still hovered keeps it, mouseleave ends it.
fireEvent.mouseEnter(anchor)
fireEvent.focus(anchor)
fireEvent.blur(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.mouseLeave(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('drops an already-visible bubble when disabled flips mid-hover', () => {
const { rerender } = render(
<Tooltip label="Rail">

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-client-ui-sidebar
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.