diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index f44287ed8a..270b8902d6 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -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; + } } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 4faa479e70..294b39b59a 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -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([]) // Transient drag viewing state (never store-bound; order truth stays Host-side). const [drag, setDrag] = useState(null) + const [workspaceDrag, setWorkspaceDrag] = useState(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 && (
{t('empty.none')}
)} - {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). -
+
{ 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({ )}
- ))} + ) + })}
@@ -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({ query: '', @@ -413,6 +469,7 @@ export function WorkspaceBrowser({ items: [], hasMore: false, }) + const searchRoot = useRef(null) const searchInput = useRef(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({ )} {wide && ( - { 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 && ( - - - + + + + { setQuery(sanitizeSearchQuery(e.target.value)) }} + onKeyDown={(e) => { + if (e.key !== 'Escape') return + setQuery('') + setSearchExpanded(false) + }} + /> + {searchExpanded && ( + + )} + + )} +
+ {wide && ( + { 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 && ( + + + + )} +
{/* Add flow + its error dialog (same package — direct composition). */} - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Rail: the icon is the region's search control. */} -
{ if (wide) searchInput.current?.focus() }}> - + {/* The collapsed rail keeps search as its own 36px control. */} + {!wide &&
+ - {wide && ( - { setQuery(sanitizeSearchQuery(e.target.value)) }} - /> - )} - {wide && query !== '' && ( - - )} -
+
} {/* 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} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index e1c41c9c17..e5487d2657 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -116,6 +116,11 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & { renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise /** Delete only a Host Workspace registration; directory and Session logs remain. */ deleteWorkspace: (workspaceId: WorkspaceId) => Promise + /** + * Reorder a Workspace in the durable registry display order. + * Omitted anchor appends to the end. + */ + insertWorkspaceBefore: (workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId) => Promise /** * Archive a Session into the registry-global set: hidden from grouping * surfaces, log and accounting slot retained. Archiving the current diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 5f499c4336..41e88116fc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -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) diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 71be79d081..0880820e4a 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -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. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 5ed290f20d..0903bbb600 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -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 = (
{ + 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)) + }} > {row.expanded ? : } @@ -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}