feat(web): session list one-list, hover card, row menus, rename, manual ordering
Sidebar session list grows the figma 239-10458 feature set and the
workspace/session browsing region moves wholesale into ui-workspace:
- Group-by menu (WorkSpace / In one list): flat mode lists every session
top-level, strictly newest-first; the choice persists across reloads.
- Session rows get a 500ms hover detail card (title / relative time /
status line) and a ... menu (Rename / Fork session / Delete session,
visual-only for now); workspace headers get ... with Rename (wired) and
Delete workspace (visual-only).
- workspace.rename RPC: trims, rejects duplicate titles on the create
chain (workspace-name-conflict), no-op on same title; modal dialog with
client-side duplicate pre-check.
- workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted
anchor appends): HTML5 drag reorder of root sessions inside a workspace
group; order truth stays host-side, the view refreshes from the
response/changed frame.
- Activity pinning removed: the session/event touchSession chain is gone;
workspace accounts are manually owned (new sessions prepend, explicit
reordering only). Contracts and tests updated, api catalog regenerated.
- ui-sidebar reduced to the column shell (brand, fold state machine, New
Session, Settings) exposing one sidebar.workspaces hole with a two-fact
owner share {wide, expandSidebar}; ui-workspace owns the whole region
(header, search, grouped/flat lists, dialogs, drag) plus the picker via
a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and
its deferral indirection are gone.
- ui-primitives: Menu gains label entries, danger rows, and
closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled
guard). Hover card and row menu never coexist.
This commit is contained in:
109
packages/client/ui-primitives/src/HoverCard.tsx
Normal file
109
packages/client/ui-primitives/src/HoverCard.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// HoverCard: delayed hover-preview card portaled to document.body.
|
||||
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
|
||||
// the card is fixed-positioned at its right edge and repositions on
|
||||
// scroll/resize while open. Display-only — the card ignores pointer events
|
||||
// and closes the instant the pointer leaves the anchor (no close delay).
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import css from './HoverCard.module.css'
|
||||
|
||||
/**
|
||||
* Render an anchor with a hover-triggered preview card.
|
||||
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
|
||||
* @param props.content - card content (display-only, no pointer interaction).
|
||||
* @param props.openDelayMs - hover dwell before the card shows (default 500).
|
||||
* @param props.disabled - suppress opening; turning true closes an open card.
|
||||
* @returns anchor wrapper with the conditional portaled card.
|
||||
*/
|
||||
export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
|
||||
anchor: ReactNode
|
||||
content: ReactNode
|
||||
openDelayMs?: number
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<CSSProperties | null>(null)
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Owner disabling mid-hover (menu opened, drag started) closes immediately.
|
||||
useEffect(() => {
|
||||
if (!disabled) return
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}, [disabled])
|
||||
|
||||
useEffect(() => clearTimer, [])
|
||||
|
||||
// Fixed-position from the anchor rect before paint; track the anchor while
|
||||
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) { setPos(null); return }
|
||||
const place = () => {
|
||||
const r = rootRef.current?.getBoundingClientRect() ?? null
|
||||
if (r === null) return
|
||||
const h = cardRef.current?.offsetHeight ?? 0
|
||||
const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top
|
||||
setPos({ left: r.right + 8, top })
|
||||
}
|
||||
place()
|
||||
window.addEventListener('scroll', place, true)
|
||||
window.addEventListener('resize', place)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', place, true)
|
||||
window.removeEventListener('resize', place)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// The first placement ran before the card mounted (height read 0): once the
|
||||
// card's real height is measurable, correct the bottom-edge clamp.
|
||||
useLayoutEffect(() => {
|
||||
if (!open || pos === null || typeof pos.top !== 'number') return
|
||||
const h = cardRef.current?.offsetHeight ?? 0
|
||||
if (pos.top + h > window.innerHeight - 8) {
|
||||
const top = window.innerHeight - h - 8
|
||||
if (pos.top !== top) setPos({ ...pos, top })
|
||||
}
|
||||
}, [open, pos])
|
||||
|
||||
const card = open && pos !== null && (
|
||||
<div ref={cardRef} className={css.card} style={pos}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={rootRef}
|
||||
className={css.root}
|
||||
onPointerEnter={() => {
|
||||
if (disabled) return
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}}
|
||||
// Any press inside the anchor (row click, menu trigger) dismisses the
|
||||
// card immediately, without waiting for the owner to flip `disabled`.
|
||||
onPointerDownCapture={() => {
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{anchor}
|
||||
{card !== false && createPortal(card, document.body)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user