feat(client): refine workspace sidebar interactions

This commit is contained in:
_Kerman
2026-08-11 13:43:51 +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); background: var(--dsw-alias-interactive-bg-hover);
} }
/* Section header: 36px, "Workspaces/Sessions" label + group-by / /* Section header: title, an inline search control, and the two trailing
new-workspace buttons; the right-anchored new-workspace button is the actions. Expanding search collapses the action cluster and takes its room. */
row's rail survivor. */
.sectionHeader { .sectionHeader {
flex: none; flex: none;
display: flex; display: flex;
@@ -48,7 +47,7 @@
justify-content: flex-end; justify-content: flex-end;
gap: 4px; gap: 4px;
height: 36px; height: 36px;
padding-left: 12px; padding-left: 4px;
margin-bottom: 4px; margin-bottom: 4px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 12px; border-radius: 12px;
@@ -56,64 +55,126 @@
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.root:not(.rail) .sectionHeader {
margin-right: -4px;
}
.sectionLabel { .sectionLabel {
flex: 1; flex: none;
max-width: 45%;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
line-height: 20px; line-height: 20px;
} }
/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off .searchSlot {
corners); rail state renders it as the flex: 1;
region's search control. Upstream binds a dedicated design-system variable min-width: 0;
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component display: flex;
token pinned to the static scale mirrors it. */ align-items: center;
.search { padding-left: 4px;
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); box-sizing: border-box;
}
.headerActions {
flex: none; flex: none;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 4px;
height: 38px; max-width: 60px;
margin: 0 2px 12px; opacity: 1;
padding: 0 14px; 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; box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2); border: 1px solid var(--dsw-alias-border-l1);
border-radius: 12px; border-radius: 10px;
background: var(--dsh-search-input-fill); background: transparent;
cursor: text;
color: var(--dsw-alias-label-caption); color: var(--dsw-alias-label-caption);
overflow: hidden; 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 { .searchExpanded {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); 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 { .searchButton {
flex: none; flex: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px;
height: 26px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
padding: 0; padding: 0;
background: transparent; background: transparent;
pointer-events: none; cursor: pointer;
color: inherit; color: inherit;
} }
.searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchExpanded .searchButton:hover {
background: transparent;
}
.searchInput { .searchInput {
flex: 1; flex: 1;
width: 0;
min-width: 0; min-width: 0;
border: none; border: none;
outline: none; outline: none;
background: transparent; background: transparent;
font-size: 14px; opacity: 0;
line-height: 20px; pointer-events: none;
font-size: 13px;
line-height: 18px;
color: var(--dsw-alias-label-primary); 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 { .searchInput::placeholder {
@@ -125,8 +186,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 28px; width: 18px;
height: 28px; height: 18px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
padding: 0; padding: 0;
@@ -144,6 +205,10 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.rail .headerActions {
max-width: none;
}
.rail .iconButton { .rail .iconButton {
width: 36px; width: 36px;
height: 36px; height: 36px;
@@ -151,6 +216,7 @@
} }
.rail .search { .rail .search {
width: 36px;
height: 36px; height: 36px;
padding: 0; padding: 0;
margin: 0 0 12px; margin: 0 0 12px;
@@ -162,8 +228,6 @@
.rail .searchButton { .rail .searchButton {
width: 36px; width: 36px;
height: 36px; height: 36px;
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
@@ -254,10 +318,35 @@
} }
/* One workspace section: header row + a compact expanded session run. */ /* One workspace section: header row + a compact expanded session run. */
.groupSection {
position: relative;
}
.groupSection + .groupSection { .groupSection + .groupSection {
margin-top: 4px; 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 { .sessionOverflowButton {
width: 100%; width: 100%;
height: 30px; height: 30px;
@@ -271,9 +360,15 @@
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.groupSection > .sessionOverflowButton {
margin-top: 0;
}
.sessionOverflowButton:hover { .sessionOverflowButton:hover {
background: var(--dsw-alias-interactive-bg-hover); background: transparent;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
text-decoration: underline;
text-underline-offset: 2px;
} }
.empty { .empty {
@@ -323,4 +418,10 @@
.wide { .wide {
animation: none; 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. */ /** `session.search` wire bound, measured in JavaScript UTF-16 code units. */
const SEARCH_QUERY_MAX_CODE_UNITS = 500 const SEARCH_QUERY_MAX_CODE_UNITS = 500
/** Session rows visible per Workspace before the local overflow control. */ /** 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. */ /** Keep controlled input and RPC payload inside the session.search wire contract. */
function sanitizeSearchQuery(value: string): string { function sanitizeSearchQuery(value: string): string {
@@ -110,9 +110,16 @@ interface DragState {
over: { id: SessionNode['id']; half: 'before' | 'after' } | null 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< type SessionTreeProps = Pick<
WorkspaceBrowserProps, WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't' 'useSessions' | 'startSession' | 'open' | 'forkSession'
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
> & { > & {
workspaces: readonly WorkspaceView[] workspaces: readonly WorkspaceView[]
/** Registry-global archive set (hidden rows). */ /** Registry-global archive set (hidden rows). */
@@ -125,14 +132,15 @@ type SessionTreeProps = Pick<
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
/** Archive a session (row menu action; the row disappears on the state echo). */ /** Archive a session (row menu action; the row disappears on the state echo). */
onSessionArchive: (sessionId: SessionNode['id']) => void 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 orderBy: SessionOrderBy
} }
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ /** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ function SessionTree({
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, orderBy, t, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
insertWorkspaceBefore, insertSessionBefore, orderBy, t,
}: SessionTreeProps) { }: SessionTreeProps) {
const list = useSessions(s => s) const list = useSessions(s => s)
const current = list.current const current = list.current
@@ -140,6 +148,7 @@ function SessionTree({
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([]) const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
// Transient drag viewing state (never store-bound; order truth stays Host-side). // Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState<DragState | null>(null) const [drag, setDrag] = useState<DragState | null>(null)
const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
const currentGroup = current === undefined const currentGroup = current === undefined
? undefined ? undefined
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
@@ -160,18 +169,62 @@ function SessionTree({
{groups.length === 0 && ( {groups.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div> <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 // Group section: header row + expanded top-level session rows. The
// inter-group breathing room is the section's own margin // inter-group breathing room is the section's own margin
// (WorkspaceBrowser.module.css). // (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 <ProjectRowItem
group={group} group={group}
t={t} 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={() => { onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId) if (group.workspaceId !== undefined) startSession(group.workspaceId)
}} }}
drag={workspaceDragProps}
actions={group.workspaceId === undefined actions={group.workspaceId === undefined
? undefined ? undefined
: { : {
@@ -251,7 +304,8 @@ function SessionTree({
</button> </button>
)} )}
</div> </div>
))} )
})}
</div> </div>
<span className={css.fade} /> <span className={css.fade} />
</div> </div>
@@ -382,6 +436,7 @@ export function WorkspaceBrowser({
forkSession, forkSession,
renameWorkspace, renameWorkspace,
deleteWorkspace, deleteWorkspace,
insertWorkspaceBefore,
archiveSession, archiveSession,
insertSessionBefore, insertSessionBefore,
createWorkspace, createWorkspace,
@@ -406,6 +461,7 @@ export function WorkspaceBrowser({
// The query outlives the tree and the input (both wide-only) so collapsing // The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter. // does not silently drop an in-progress filter.
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [searchExpanded, setSearchExpanded] = useState(false)
const normalizedQuery = sanitizeSearchQuery(query).trim() const normalizedQuery = sanitizeSearchQuery(query).trim()
const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({ const [remoteSearch, setRemoteSearch] = useState<RemoteSearchState>({
query: '', query: '',
@@ -413,6 +469,7 @@ export function WorkspaceBrowser({
items: [], items: [],
hasMore: false, hasMore: false,
}) })
const searchRoot = useRef<HTMLDivElement | null>(null)
const searchInput = useRef<HTMLInputElement | null>(null) const searchInput = useRef<HTMLInputElement | null>(null)
// Section-header + opens the picker menu (same popover in wide and rail // Section-header + opens the picker menu (same popover in wide and rail
// states; the menu anchors on this button). // states; the menu anchors on this button).
@@ -433,6 +490,21 @@ export function WorkspaceBrowser({
} }
}, [wide, searchOnExpand]) }, [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(() => { useEffect(() => {
if (normalizedQuery === '') { if (normalizedQuery === '') {
setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false }) setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false })
@@ -585,32 +657,91 @@ export function WorkspaceBrowser({
</span> </span>
)} )}
{wide && ( {wide && (
<ViewOptionsMenu <div className={css.searchSlot}>
groupBy={groupBy} <div
orderBy={effectiveOrderBy} ref={searchRoot}
onGroupPick={(mode) => { actions.setGroupBy(mode) }} className={clsx(css.search, searchExpanded && css.searchExpanded)}
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={() => { onClick={() => {
setWsPickerOpen(v => !v) setWsPickerOpen(false)
setSearchExpanded(true)
searchInput.current?.focus()
}} }}
> >
<IconProjectAddOutline16 size={wide ? 16 : 18} /> <Tooltip label={t('search')} disabled={searchExpanded}>
</button> <button
</Tooltip> 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). */} {/* Add flow + its error dialog (same package — direct composition). */}
<WorkspacePickFlow <WorkspacePickFlow
t={t} t={t}
@@ -630,42 +761,23 @@ export function WorkspaceBrowser({
/> />
</div> </div>
{/* Expanded: the row is a click-to-focus field (the leading icon is {/* The collapsed rail keeps search as its own 36px control. */}
decorative). Rail: the icon is the region's search control. */} {!wide && <div className={css.search}>
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}> <Tooltip label={t('search')}>
<Tooltip label={t('search')} disabled={wide}>
<button <button
type="button" type="button"
className={css.searchButton} className={css.searchButton}
aria-label={t('search.sessions.aria')} aria-label={t('search.sessions.aria')}
tabIndex={wide ? -1 : 0} onClick={() => {
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }} setSearchExpanded(true)
setSearchOnExpand(true)
expandSidebar()
}}
> >
<IconSearchOutline16 size={wide ? 14 : 18} /> <IconSearchOutline16 size={18} />
</button> </button>
</Tooltip> </Tooltip>
{wide && ( </div>}
<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>
{/* Always-mounted seat keeps the region's flex slot while the list {/* Always-mounted seat keeps the region's flex slot while the list
itself is wide-only. */} itself is wide-only. */}
@@ -701,6 +813,7 @@ export function WorkspaceBrowser({
archivedSessionIds={archivedSessionIds} archivedSessionIds={archivedSessionIds}
startSession={startSession} startSession={startSession}
open={open} open={open}
insertWorkspaceBefore={insertWorkspaceBefore}
insertSessionBefore={insertSessionBefore} insertSessionBefore={insertSessionBefore}
orderBy={orderBy} orderBy={orderBy}
t={t} t={t}
@@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void> renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */ /** Delete only a Host Workspace registration; directory and Session logs remain. */
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void> 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 * Archive a Session into the registry-global set: hidden from grouping
* surfaces, log and accounting slot retained. Archiving the current * 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) }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) }, 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) }, archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
@@ -21,7 +21,7 @@
} }
.sessionRow.selected { .sessionRow.selected {
background: var(--dsw-alias-interactive-bg-active); background: var(--dsw-alias-interactive-bg-hover);
} }
.searchResultRow { .searchResultRow {
@@ -45,7 +45,7 @@
} }
.searchResultRow.selected { .searchResultRow.selected {
background: var(--dsw-alias-interactive-bg-active); background: var(--dsw-alias-interactive-bg-hover);
} }
.searchResultHeading { .searchResultHeading {
@@ -229,14 +229,32 @@
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
/* Drag reorder insert line (workspace-group session rows): 2px accent above or /* Session drag insert line: an independent 2px rule between rows, absolutely
below the hovered row, drawn with box-shadow so no layout shift. */ positioned so it neither resembles a row border nor changes layout. */
.sessionRow.dropBefore { .sessionRow.dropBefore,
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); .sessionRow.dropAfter {
position: relative;
} }
.sessionRow.dropAfter { .sessionRow.dropBefore::before,
box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); .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. */ /* 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; * Project (workspace) header row: folder + title;
* hover reveals the chevron and create button, and dwelling on a real * 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.group - derived group node.
* @param props.onToggle - expand/collapse the group. * @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace. * @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. * @param props.t - the browser root's locale seat.
* @returns the row element. * @returns the row element.
*/ */
export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }: {
group: GroupNode group: GroupNode
onToggle: () => void onToggle: () => void
onCreate: () => void onCreate: () => void
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */ /** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
actions?: { rename: () => void; delete: () => void } | undefined actions?: { rename: () => void; delete: () => void } | undefined
/** Present only for real Workspace rows in the grouped view. */
drag?: RowDragProps | undefined
t: RowTranslate t: RowTranslate
}) { }) {
const row = group const row = group
@@ -96,10 +122,37 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
] ]
const ownRow = ( const ownRow = (
<div <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" role="treeitem"
aria-expanded={row.expanded} aria-expanded={row.expanded}
onClick={onToggle} 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)}> <span className={clsx(css.slot, css.folder, active && css.folderActive)}>
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />} {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 * One flat search result: title, Workspace context, and optional content
* excerpt. Search navigation opens the session only; it does not address an * 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 * One top-level 34px session row: status dot (pending user interaction outranks
* own or descendant activity), title, relative time, and the row actions menu. * 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 ? undefined
: (e) => { : (e) => {
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', node.id)
drag.start() drag.start()
}} }}
onDragEnd={drag?.end} onDragEnd={drag?.end}