fix(client): preserve workspace browser interactions
This commit is contained in:
@@ -36,6 +36,7 @@ const SEARCH_DEBOUNCE_MS = 250
|
||||
const SEARCH_QUERY_MAX_CODE_UNITS = 500
|
||||
/** Session rows visible per Workspace before the local overflow control. */
|
||||
const COLLAPSED_SESSION_LIMIT = 5
|
||||
const EMPTY_WORKSPACE_EXPANSION: Readonly<Record<string, boolean>> = Object.freeze({})
|
||||
|
||||
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
||||
function sanitizeSearchQuery(value: string): string {
|
||||
@@ -48,7 +49,7 @@ function sanitizeSearchQuery(value: string): string {
|
||||
return withoutNul.slice(0, end)
|
||||
}
|
||||
|
||||
/** Immutable membership toggle for the local expansion arrays. */
|
||||
/** Immutable membership toggle for the local expand-all array. */
|
||||
function toggled(list: readonly string[], key: string): string[] {
|
||||
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
|
||||
}
|
||||
@@ -116,12 +117,22 @@ interface WorkspaceDragState {
|
||||
over: { id: WorkspaceId; half: 'before' | 'after' } | null
|
||||
}
|
||||
|
||||
/** Resolve an insertion side from the full rendered workspace group. */
|
||||
function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
type SessionTreeProps = Pick<
|
||||
WorkspaceBrowserProps,
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession'
|
||||
| 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Explicit persisted zero-or-five-session state by Workspace group. */
|
||||
workspaceExpansion: Readonly<Record<string, boolean>>
|
||||
/** Persist one Workspace group's zero-or-five-session state. */
|
||||
setWorkspaceExpanded: (key: string, expanded: boolean) => void
|
||||
/** Registry-global archive set (hidden rows). */
|
||||
archivedSessionIds: readonly SessionNode['id'][]
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
@@ -136,15 +147,15 @@ type SessionTreeProps = Pick<
|
||||
orderBy: SessionOrderBy
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
/** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
|
||||
insertWorkspaceBefore, insertSessionBefore, orderBy, t,
|
||||
insertWorkspaceBefore, insertSessionBefore, orderBy,
|
||||
workspaceExpansion, setWorkspaceExpanded, t,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
|
||||
// Transient drag viewing state (never store-bound; order truth stays Host-side).
|
||||
const [drag, setDrag] = useState<DragState | null>(null)
|
||||
@@ -154,9 +165,13 @@ function SessionTree({
|
||||
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
|
||||
?? UNGROUPED_KEY
|
||||
useEffect(() => {
|
||||
if (current === undefined || currentGroup === undefined) return
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
if (current === undefined || currentGroup === undefined || Object.hasOwn(workspaceExpansion, currentGroup)) return
|
||||
setWorkspaceExpanded(currentGroup, true)
|
||||
}, [current, currentGroup, setWorkspaceExpanded, workspaceExpansion])
|
||||
const expandedProjects = useMemo(
|
||||
() => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
|
||||
[workspaceExpansion],
|
||||
)
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy),
|
||||
[list, workspaces, archivedSessionIds, expandedProjects, orderBy],
|
||||
@@ -176,14 +191,18 @@ function SessionTree({
|
||||
: null
|
||||
const workspaceDragProps = workspaceId === undefined ? undefined : {
|
||||
start: () => { setWorkspaceDrag({ workspaceId, over: null }) },
|
||||
active: workspaceDrag !== null,
|
||||
marker: null,
|
||||
hover: (half: 'before' | 'after') => {
|
||||
end: () => { setWorkspaceDrag(null) },
|
||||
}
|
||||
const hoverWorkspace = workspaceId === undefined
|
||||
? undefined
|
||||
: (half: 'before' | 'after') => {
|
||||
setWorkspaceDrag(active => active === null
|
||||
? active
|
||||
: { ...active, over: { id: workspaceId, half } })
|
||||
},
|
||||
drop: (half: 'before' | 'after') => {
|
||||
}
|
||||
const dropWorkspace = workspaceId === undefined
|
||||
? undefined
|
||||
: (half: 'before' | 'after') => {
|
||||
if (workspaceDrag === null) return
|
||||
const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId)
|
||||
const anchor = half === 'before' ? workspaceId : workspaces[rowIndex + 1]?.workspaceId
|
||||
@@ -197,9 +216,7 @@ function SessionTree({
|
||||
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
|
||||
@@ -211,6 +228,19 @@ function SessionTree({
|
||||
workspaceMarker === 'before' && css.workspaceDropBefore,
|
||||
workspaceMarker === 'after' && css.workspaceDropAfter,
|
||||
)}
|
||||
onDragOver={workspaceDrag === null || hoverWorkspace === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
hoverWorkspace(workspaceGroupHalf(e))
|
||||
}}
|
||||
onDrop={workspaceDrag === null || dropWorkspace === undefined
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.preventDefault()
|
||||
dropWorkspace(workspaceGroupHalf(e))
|
||||
}}
|
||||
>
|
||||
<ProjectRowItem
|
||||
group={group}
|
||||
@@ -219,7 +249,7 @@ function SessionTree({
|
||||
if (group.expanded) {
|
||||
setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
|
||||
}
|
||||
setExpandedProjects(l => toggled(l, group.key))
|
||||
setWorkspaceExpanded(group.key, !group.expanded)
|
||||
}}
|
||||
onCreate={() => {
|
||||
if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
||||
@@ -458,6 +488,8 @@ export function WorkspaceBrowser({
|
||||
// A flat list has no single Workspace account to drag. Keep the stored
|
||||
// grouped preference intact while presenting the flat list by recency.
|
||||
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
|
||||
// HMR can retain the preceding view-store instance until the slot remounts.
|
||||
const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION)
|
||||
// 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('')
|
||||
@@ -497,12 +529,14 @@ export function WorkspaceBrowser({
|
||||
|
||||
useEffect(() => {
|
||||
if (!wide || !searchExpanded) return
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
|
||||
searchInput.current?.blur()
|
||||
setQuery('')
|
||||
setSearchExpanded(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown)
|
||||
return () => { document.removeEventListener('pointerdown', onPointerDown) }
|
||||
document.addEventListener('click', onClick)
|
||||
return () => { document.removeEventListener('click', onClick) }
|
||||
}, [wide, searchExpanded])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -810,6 +844,8 @@ export function WorkspaceBrowser({
|
||||
onSessionArchive={onSessionArchive}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
workspaceExpansion={workspaceExpansion}
|
||||
setWorkspaceExpanded={actions.setWorkspaceExpanded}
|
||||
archivedSessionIds={archivedSessionIds}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
|
||||
@@ -83,6 +83,12 @@ export interface RowDragProps {
|
||||
end: () => void
|
||||
}
|
||||
|
||||
/** Drag lifecycle owned by a workspace row; its enclosing group owns hit testing. */
|
||||
interface WorkspaceRowDragProps {
|
||||
start: () => 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()
|
||||
@@ -108,7 +114,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
|
||||
/** 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
|
||||
drag?: WorkspaceRowDragProps | undefined
|
||||
t: RowTranslate
|
||||
}) {
|
||||
const row = group
|
||||
@@ -122,10 +128,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
|
||||
]
|
||||
const ownRow = (
|
||||
<div
|
||||
className={clsx(
|
||||
css.projectRow, menuOpen && css.menuOpen,
|
||||
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
|
||||
)}
|
||||
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
|
||||
role="treeitem"
|
||||
aria-expanded={row.expanded}
|
||||
onClick={onToggle}
|
||||
@@ -138,21 +141,6 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, drag, t }:
|
||||
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 />}
|
||||
|
||||
@@ -12,8 +12,13 @@ export type WorkspaceGroupBy = 'workspace' | 'flat'
|
||||
/** Session order: durable Workspace order or a derived timestamp order. */
|
||||
export type WorkspaceOrderBy = 'manual' | 'created' | 'updated'
|
||||
|
||||
/** Workspace browser viewing state; transient expansion facts stay component-local. */
|
||||
type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy }
|
||||
/** Workspace browser viewing state persisted across surface remounts and reloads. */
|
||||
type WorkspaceViewState = {
|
||||
groupBy: WorkspaceGroupBy
|
||||
orderBy: WorkspaceOrderBy
|
||||
/** Explicit zero-or-five-session state keyed by Workspace group identity. */
|
||||
workspaceExpansion: Record<string, boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
@@ -22,6 +27,7 @@ type WorkspaceViewState = { groupBy: WorkspaceGroupBy; orderBy: WorkspaceOrderBy
|
||||
type WorkspaceViewActions = {
|
||||
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
||||
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
|
||||
setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,12 +36,12 @@ type WorkspaceViewActions = {
|
||||
*/
|
||||
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
|
||||
return defineStore({
|
||||
init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual' }),
|
||||
// The added order field changes the whole-value persistence format.
|
||||
persist: 'dsh.workspace.view.v2',
|
||||
init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }),
|
||||
persist: 'dsh.workspace.view.v3',
|
||||
actions: {
|
||||
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
||||
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
|
||||
setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user