feat(client): refine workspace sidebar interactions
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user