feat(client): refine workspace sidebar interactions

This commit is contained in:
_Kerman
2026-08-11 15:25:11 +08:00
parent b3e843056e
commit 1a1729a138
6 changed files with 394 additions and 124 deletions
@@ -38,9 +38,8 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Section header: 36px, "Workspaces/Sessions" label + group-by /
new-workspace buttons; the right-anchored new-workspace button is the
row's rail survivor. */
/* Section header: title, an inline search control, and the two trailing
actions. Expanding search collapses the action cluster and takes its room. */
.sectionHeader {
flex: none;
display: flex;
@@ -48,7 +47,7 @@
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: 12px;
padding-left: 4px;
margin-bottom: 4px;
box-sizing: border-box;
border-radius: 12px;
@@ -56,64 +55,126 @@
color: var(--dsw-alias-label-tertiary);
}
.root:not(.rail) .sectionHeader {
margin-right: -4px;
}
.sectionLabel {
flex: 1;
flex: none;
max-width: 45%;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
}
/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off
corners); rail state renders it as the
region's search control. Upstream binds a dedicated design-system variable
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
token pinned to the static scale mirrors it. */
.search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
.searchSlot {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding-left: 4px;
box-sizing: border-box;
}
.headerActions {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 38px;
margin: 0 2px 12px;
padding: 0 14px;
gap: 4px;
max-width: 60px;
opacity: 1;
overflow: hidden;
visibility: visible;
transition:
max-width 180ms var(--ds-ease-in-out),
opacity 120ms var(--ds-ease-in-out),
transform 180ms var(--ds-ease-in-out),
visibility 0s linear;
}
.headerActionsHidden {
max-width: 0;
opacity: 0;
transform: translateX(4px);
visibility: hidden;
pointer-events: none;
transition-delay: 0s, 0s, 0s, 180ms;
}
/* Inline search always fills the room between the title and trailing actions;
it grows farther right when the action cluster collapses. */
.search {
flex: none;
display: flex;
align-items: center;
gap: 0;
width: 100%;
height: 26px;
margin: 0;
padding: 0;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsh-search-input-fill);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 10px;
background: transparent;
cursor: text;
color: var(--dsw-alias-label-caption);
overflow: hidden;
transition:
width 180ms var(--ds-ease-in-out),
padding 180ms var(--ds-ease-in-out),
border-color 180ms var(--ds-ease-in-out),
background-color 180ms var(--ds-ease-in-out);
}
:global(body[data-ds-dark-theme]) .search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
.searchExpanded {
padding: 0 4px 0 0;
border-color: var(--dsw-alias-border-l2);
background: transparent;
}
/* The capsule's leading icon: decorative while wide (pointer-events off so
clicks reach the input), the hit target in rail state. */
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
pointer-events: none;
cursor: pointer;
color: inherit;
}
.searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchExpanded .searchButton:hover {
background: transparent;
}
.searchInput {
flex: 1;
width: 0;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 14px;
line-height: 20px;
opacity: 0;
pointer-events: none;
font-size: 13px;
line-height: 18px;
color: var(--dsw-alias-label-primary);
transition: opacity 120ms var(--ds-ease-in-out);
}
.searchExpanded .searchInput {
margin-left: -2px;
opacity: 1;
pointer-events: auto;
}
.searchInput::placeholder {
@@ -125,8 +186,8 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
padding: 0;
@@ -144,6 +205,10 @@
margin-bottom: 12px;
}
.rail .headerActions {
max-width: none;
}
.rail .iconButton {
width: 36px;
height: 36px;
@@ -151,6 +216,7 @@
}
.rail .search {
width: 36px;
height: 36px;
padding: 0;
margin: 0 0 12px;
@@ -162,8 +228,6 @@
.rail .searchButton {
width: 36px;
height: 36px;
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-primary);
}
@@ -254,10 +318,35 @@
}
/* One workspace section: header row + a compact expanded session run. */
.groupSection {
position: relative;
}
.groupSection + .groupSection {
margin-top: 4px;
}
.workspaceDropBefore::before,
.workspaceDropAfter::after {
content: '';
position: absolute;
z-index: 1;
left: 4px;
right: 4px;
height: 2px;
border-radius: 999px;
background: var(--dsw-alias-state-business-primary);
pointer-events: none;
}
.workspaceDropBefore::before {
top: -3px;
}
.workspaceDropAfter::after {
bottom: -3px;
}
.sessionOverflowButton {
width: 100%;
height: 30px;
@@ -271,9 +360,15 @@
color: var(--dsw-alias-label-tertiary);
}
.groupSection > .sessionOverflowButton {
margin-top: 0;
}
.sessionOverflowButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
background: transparent;
color: var(--dsw-alias-label-secondary);
text-decoration: underline;
text-underline-offset: 2px;
}
.empty {
@@ -323,4 +418,10 @@
.wide {
animation: none;
}
.search,
.searchInput,
.headerActions {
transition: none;
}
}
@@ -35,7 +35,7 @@ const SEARCH_DEBOUNCE_MS = 250
/** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500
/** Session rows visible per Workspace before the local overflow control. */
const COLLAPSED_SESSION_LIMIT = 6
const COLLAPSED_SESSION_LIMIT = 5
/** Keep controlled input and RPC payload inside the session.search wire contract. */
function sanitizeSearchQuery(value: string): string {
@@ -110,9 +110,16 @@ interface DragState {
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
}
/** In-flight Workspace-row drag: source identity plus the current marker. */
interface WorkspaceDragState {
workspaceId: WorkspaceId
over: { id: WorkspaceId; half: 'before' | 'after' } | null
}
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
'useSessions' | 'startSession' | 'open' | 'forkSession'
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
> & {
workspaces: readonly WorkspaceView[]
/** Registry-global archive set (hidden rows). */
@@ -125,14 +132,15 @@ type SessionTreeProps = Pick<
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
/** Archive a session (row menu action; the row disappears on the state echo). */
onSessionArchive: (sessionId: SessionNode['id']) => void
/** Visual order; only manual mode exposes durable Workspace dragging. */
/** Session visual order; only manual mode exposes durable Session dragging. */
orderBy: SessionOrderBy
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
insertWorkspaceBefore, insertSessionBefore, orderBy, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
@@ -140,6 +148,7 @@ function SessionTree({
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
// Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState<DragState | null>(null)
const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
const currentGroup = current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
@@ -160,18 +169,62 @@ function SessionTree({
{groups.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div>
)}
{groups.map(group => (
{groups.map((group) => {
const workspaceId = group.workspaceId
const workspaceMarker = workspaceId !== undefined && workspaceDrag?.over?.id === workspaceId
? workspaceDrag.over.half
: null
const workspaceDragProps = workspaceId === undefined ? undefined : {
start: () => { setWorkspaceDrag({ workspaceId, over: null }) },
active: workspaceDrag !== null,
marker: null,
hover: (half: 'before' | 'after') => {
setWorkspaceDrag(active => active === null
? active
: { ...active, over: { id: workspaceId, half } })
},
drop: (half: 'before' | 'after') => {
if (workspaceDrag === null) return
const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId
setWorkspaceDrag(null)
if (anchor === workspaceDrag.workspaceId) return
const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceDrag.workspaceId)
const anchorIndex = anchor === undefined
? workspaces.length
: workspaces.findIndex(workspace => workspace.workspaceId === anchor)
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
insertWorkspaceBefore(workspaceDrag.workspaceId, anchor).catch((reason: unknown) => {
console.warn('workspace reorder rejected:', reason)
})
},
end: () => { setWorkspaceDrag(null) },
}
return (
// Group section: header row + expanded top-level session rows. The
// inter-group breathing room is the section's own margin
// (WorkspaceBrowser.module.css).
<div key={group.key} className={css.groupSection}>
<div
key={group.key}
className={clsx(
css.groupSection,
workspaceMarker === 'before' && css.workspaceDropBefore,
workspaceMarker === 'after' && css.workspaceDropAfter,
)}
>
<ProjectRowItem
group={group}
t={t}
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
onToggle={() => {
if (group.expanded) {
setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
}
setExpandedProjects(l => toggled(l, group.key))
}}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
drag={workspaceDragProps}
actions={group.workspaceId === undefined
? undefined
: {
@@ -251,7 +304,8 @@ function SessionTree({
</button>
)}
</div>
))}
)
})}
</div>
<span className={css.fade} />
</div>
@@ -382,6 +436,7 @@ export function WorkspaceBrowser({
forkSession,
renameWorkspace,
deleteWorkspace,
insertWorkspaceBefore,
archiveSession,
insertSessionBefore,
createWorkspace,
@@ -406,6 +461,7 @@ export function WorkspaceBrowser({
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const [searchExpanded, setSearchExpanded] = useState(false)
const normalizedQuery = sanitizeSearchQuery(query).trim()
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
query: '',
@@ -413,6 +469,7 @@ export function WorkspaceBrowser({
items: [],
hasMore: false,
})
const searchRoot = useRef<HTMLDivElement | null>(null)
const searchInput = useRef<HTMLInputElement | null>(null)
// Section-header opens the picker menu (same popover in wide and rail
// states; the menu anchors on this button).
@@ -433,6 +490,21 @@ export function WorkspaceBrowser({
}
}, [wide, searchOnExpand])
useEffect(() => {
if (!wide || !searchExpanded || searchOnExpand) return
searchInput.current?.focus({ preventScroll: true })
}, [wide, searchExpanded, searchOnExpand])
useEffect(() => {
if (!wide || !searchExpanded) return
const onPointerDown = (event: PointerEvent): void => {
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
searchInput.current?.blur()
}
document.addEventListener('pointerdown', onPointerDown)
return () => { document.removeEventListener('pointerdown', onPointerDown) }
}, [wide, searchExpanded])
useEffect(() => {
if (normalizedQuery === '') {
setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false })
@@ -585,32 +657,91 @@ export function WorkspaceBrowser({
</span>
)}
{wide && (
<ViewOptionsMenu
groupBy={groupBy}
orderBy={effectiveOrderBy}
onGroupPick={(mode) => { actions.setGroupBy(mode) }}
onOrderPick={(mode) => { actions.setOrderBy(mode) }}
t={t}
/>
)}
{/* Adding is the button's one action, so a composition with no
picking affordance has nothing to offer here: the region hides the
button rather than leaving a dead one in the header. */}
{directoryFlowAvailable && (
<Tooltip label={t('workspace.add')} side="bottom" delayMs={500}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label={t('workspace.add')}
<div className={css.searchSlot}>
<div
ref={searchRoot}
className={clsx(css.search, searchExpanded && css.searchExpanded)}
onClick={() => {
setWsPickerOpen(v => !v)
setWsPickerOpen(false)
setSearchExpanded(true)
searchInput.current?.focus()
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
<Tooltip label={t('search')} disabled={searchExpanded}>
<button
type="button"
className={css.searchButton}
aria-label={t('search.sessions.aria')}
aria-expanded={searchExpanded}
onClick={() => {
setWsPickerOpen(false)
setSearchExpanded(true)
}}
>
<IconSearchOutline16 size={11} />
</button>
</Tooltip>
<input
ref={searchInput}
className={css.searchInput}
type="text"
placeholder={t('search.placeholder')}
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
value={query}
tabIndex={searchExpanded ? 0 : -1}
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
onKeyDown={(e) => {
if (e.key !== 'Escape') return
setQuery('')
setSearchExpanded(false)
}}
/>
{searchExpanded && (
<button
type="button"
className={css.clearButton}
aria-label={t('search.clear')}
onClick={(e) => {
e.stopPropagation()
setQuery('')
setSearchExpanded(false)
}}
>
<IconCloseFill14 />
</button>
)}
</div>
</div>
)}
<div className={clsx(css.headerActions, wide && searchExpanded && css.headerActionsHidden)}>
{wide && (
<ViewOptionsMenu
groupBy={groupBy}
orderBy={effectiveOrderBy}
onGroupPick={(mode) => { actions.setGroupBy(mode) }}
onOrderPick={(mode) => { actions.setOrderBy(mode) }}
t={t}
/>
)}
{/* Adding is the button's one action, so a composition with no
picking affordance has nothing to offer here: the region hides the
button rather than leaving a dead one in the header. */}
{directoryFlowAvailable && (
<Tooltip label={t('workspace.add')} side="bottom" delayMs={500}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label={t('workspace.add')}
onClick={() => {
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
)}
</div>
{/* Add flow + its error dialog (same package — direct composition). */}
<WorkspacePickFlow
t={t}
@@ -630,42 +761,23 @@ export function WorkspaceBrowser({
/>
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Rail: the icon is the region's search control. */}
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
<Tooltip label={t('search')} disabled={wide}>
{/* The collapsed rail keeps search as its own 36px control. */}
{!wide && <div className={css.search}>
<Tooltip label={t('search')}>
<button
type="button"
className={css.searchButton}
aria-label={t('search.sessions.aria')}
tabIndex={wide ? -1 : 0}
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
onClick={() => {
setSearchExpanded(true)
setSearchOnExpand(true)
expandSidebar()
}}
>
<IconSearchOutline16 size={wide ? 14 : 18} />
<IconSearchOutline16 size={18} />
</button>
</Tooltip>
{wide && (
<input
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder={t('search.placeholder')}
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
value={query}
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label={t('search.clear')}
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</div>
</div>}
{/* Always-mounted seat keeps the region's flex slot while the list
itself is wide-only. */}
@@ -701,6 +813,7 @@ export function WorkspaceBrowser({
archivedSessionIds={archivedSessionIds}
startSession={startSession}
open={open}
insertWorkspaceBefore={insertWorkspaceBefore}
insertSessionBefore={insertSessionBefore}
orderBy={orderBy}
t={t}
@@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Reorder a Workspace in the durable registry display order.
* Omitted anchor appends to the end.
*/
insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise<void>
/**
* Archive a Session into the registry-global set: hidden from grouping
* surfaces, log and accounting slot retained. Archiving the current
@@ -91,6 +91,9 @@ export function apply(ctx: ClientContext): void {
},
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => {
await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId)
},
archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
@@ -21,7 +21,7 @@
}
.sessionRow.selected {
background: var(--dsw-alias-interactive-bg-active);
background: var(--dsw-alias-interactive-bg-hover);
}
.searchResultRow {
@@ -45,7 +45,7 @@
}
.searchResultRow.selected {
background: var(--dsw-alias-interactive-bg-active);
background: var(--dsw-alias-interactive-bg-hover);
}
.searchResultHeading {
@@ -229,14 +229,32 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Drag reorder insert line (workspace-group session rows): 2px accent above or
below the hovered row, drawn with box-shadow so no layout shift. */
.sessionRow.dropBefore {
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
/* Session drag insert line: an independent 2px rule between rows, absolutely
positioned so it neither resembles a row border nor changes layout. */
.sessionRow.dropBefore,
.sessionRow.dropAfter {
position: relative;
}
.sessionRow.dropAfter {
box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary);
.sessionRow.dropBefore::before,
.sessionRow.dropAfter::after {
content: '';
position: absolute;
z-index: 1;
left: 4px;
right: 4px;
height: 2px;
border-radius: 999px;
background: var(--dsw-alias-state-business-primary);
pointer-events: none;
}
.sessionRow.dropBefore::before {
top: -2px;
}
.sessionRow.dropAfter::after {
bottom: -2px;
}
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
@@ -66,6 +66,29 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
)
}
/**
* Row drag wiring supplied by the tree owner. `drop` reports the half of the
* row where the pointer released so the owner can resolve an insert anchor.
*/
export interface RowDragProps {
/** Start dragging this row. */
start: () => void
/** A compatible row drag is in flight. */
active: boolean
/** Current marker on this row: insert line above, below, or none. */
marker: 'before' | 'after' | null
/** Report the hovered half while a compatible drag passes over this row. */
hover: (half: 'before' | 'after') => void
drop: (half: 'before' | 'after') => void
end: () => void
}
/** Pointer-position half of a row (insert line above or below). */
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
const rect = e.currentTarget.getBoundingClientRect()
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
/**
* Project (workspace) header row: folder + title;
* hover reveals the chevron and create button, and dwelling on a real
@@ -74,15 +97,18 @@ function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @param props.drag - optional workspace-row drag wiring.
* @param props.t - the browser root's locale seat.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
actions?: { rename: () => void; delete: () => void } | undefined
/** Present only for real Workspace rows in the grouped view. */
drag?: RowDragProps | undefined
t: RowTranslate
}) {
const row = group
@@ -96,10 +122,37 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
]
const ownRow = (
<div
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
className={clsx(
css.projectRow, menuOpen && css.menuOpen,
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
)}
role="treeitem"
aria-expanded={row.expanded}
onClick={onToggle}
draggable={drag !== undefined}
onDragStart={drag === undefined
? undefined
: (e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', row.key)
drag.start()
}}
onDragEnd={drag?.end}
onDragOver={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drag.hover(rowHalf(e))
}}
onDrop={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
drag.drop(rowHalf(e))
}}
>
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
@@ -237,24 +290,6 @@ function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number;
)
}
/**
* Session-row drag wiring supplied by the group owner (workspace groups only).
* `drop` reports the half of the row the pointer released on: 'before'
* inserts above this row, 'after' below it (the owner resolves the anchor).
*/
export interface RowDragProps {
/** Start dragging this row. */
start: () => void
/** A drag from the same group is in flight (rows show insert markers). */
active: boolean
/** Current marker on this row: insert line above, below, or none. */
marker: 'before' | 'after' | null
/** Report the hovered half while a same-group drag passes over this row. */
hover: (half: 'before' | 'after') => void
drop: (half: 'before' | 'after') => void
end: () => void
}
/**
* One flat search result: title, Workspace context, and optional content
* excerpt. Search navigation opens the session only; it does not address an
@@ -303,12 +338,6 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
)
}
/** Pointer-position half of a row (insert line above or below). */
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
const rect = e.currentTarget.getBoundingClientRect()
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
/**
* One top-level 34px session row: status dot (pending user interaction outranks
* own or descendant activity), title, relative time, and the row actions menu.
@@ -368,6 +397,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
? undefined
: (e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', node.id)
drag.start()
}}
onDragEnd={drag?.end}