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