|
|
@@ -1,6 +1,6 @@
|
|
|
/**
|
|
|
* The workspace/session browsing region filling the sidebar shell's
|
|
|
- * `sidebar.workspaces` hole: section header (title + group-by + add
|
|
|
+ * `sidebar.workspaces` hole: section header (title + view options + add
|
|
|
* workspace), search, the grouped tree or flat list, and the workspace
|
|
|
* dialogs. Wide state renders the full browser; rail state renders the two
|
|
|
* region icons (search / add workspace), each requesting shell expansion
|
|
|
@@ -16,12 +16,13 @@ import {
|
|
|
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
|
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
import type {
|
|
|
- SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
|
|
+ SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView,
|
|
|
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
import type { WorkspaceBrowserProps } from './contract/slots.ts'
|
|
|
-import type { SessionNode } from './tree.ts'
|
|
|
+import type { SessionNode, SessionOrderBy } from './tree.ts'
|
|
|
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
|
|
|
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
|
|
|
+import { FLAT_SESSION_ORDER_KEY } from './stores.ts'
|
|
|
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
|
|
|
import css from './WorkspaceBrowser.module.css'
|
|
|
|
|
|
@@ -34,6 +35,8 @@ const EXPAND_SLIDE_MS = 300
|
|
|
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 = 5
|
|
|
|
|
|
/** Keep controlled input and RPC payload inside the session.search wire contract. */
|
|
|
function sanitizeSearchQuery(value: string): string {
|
|
|
@@ -46,15 +49,106 @@ 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]
|
|
|
}
|
|
|
|
|
|
-/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
|
|
-function GroupByMenu({ groupBy, onPick, t }: {
|
|
|
+/**
|
|
|
+ * Accept the native drag at document level while a row drag is active: row
|
|
|
+ * hover still owns the insertion marker, and releasing outside the list must
|
|
|
+ * not be rendered as a rejected drop before dragend commits that last marker.
|
|
|
+ */
|
|
|
+function useNativeDragAcceptance(active: boolean): void {
|
|
|
+ useEffect(() => {
|
|
|
+ if (!active) return
|
|
|
+ const acceptDrag = (event: DragEvent): void => {
|
|
|
+ event.preventDefault()
|
|
|
+ if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
|
|
|
+ }
|
|
|
+ const acceptDrop = (event: DragEvent): void => { event.preventDefault() }
|
|
|
+ document.addEventListener('dragover', acceptDrag)
|
|
|
+ document.addEventListener('drop', acceptDrop)
|
|
|
+ return () => {
|
|
|
+ document.removeEventListener('dragover', acceptDrag)
|
|
|
+ document.removeEventListener('drop', acceptDrop)
|
|
|
+ }
|
|
|
+ }, [active])
|
|
|
+}
|
|
|
+
|
|
|
+/** Reconcile a stored view order with the Workspace's current session account. */
|
|
|
+function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] {
|
|
|
+ if (stored === undefined) return [...sessionIds]
|
|
|
+ const byId = new Map(sessionIds.map(id => [id as string, id]))
|
|
|
+ const ordered: SessionId[] = []
|
|
|
+ const included = new Set<string>()
|
|
|
+ for (const key of stored) {
|
|
|
+ const id = byId.get(key)
|
|
|
+ if (id === undefined || included.has(key)) continue
|
|
|
+ ordered.push(id)
|
|
|
+ included.add(key)
|
|
|
+ }
|
|
|
+ for (const id of sessionIds) {
|
|
|
+ if (included.has(id)) continue
|
|
|
+ ordered.push(id)
|
|
|
+ }
|
|
|
+ return ordered
|
|
|
+}
|
|
|
+
|
|
|
+/** Newest update first with stable Session identity as the tie-break. */
|
|
|
+function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number {
|
|
|
+ const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY
|
|
|
+ const bUpdatedAt = byId[b]?.updatedAt ?? Number.NEGATIVE_INFINITY
|
|
|
+ if (aUpdatedAt !== bUpdatedAt) return bUpdatedAt - aUpdatedAt
|
|
|
+ return a < b ? -1 : 1
|
|
|
+}
|
|
|
+
|
|
|
+/** Reconcile one editable order account and apply its activity-promotion policy. */
|
|
|
+function nextSessionOrderAccount({
|
|
|
+ sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency,
|
|
|
+}: {
|
|
|
+ sessionIds: readonly SessionId[]
|
|
|
+ previousOrder: readonly string[] | undefined
|
|
|
+ previousUpdatedAt: Readonly<Record<string, number>>
|
|
|
+ list: SessionListState
|
|
|
+ orderBy: SessionOrderBy
|
|
|
+ sortByRecency: boolean
|
|
|
+}): { order: SessionId[]; updatedAt: Record<string, number>; changed: boolean } {
|
|
|
+ let order = reconciledSessionOrder(sessionIds, previousOrder)
|
|
|
+ if (sortByRecency) {
|
|
|
+ order.sort((a, b) => compareSessionRecency(a, b, list.byId))
|
|
|
+ } else if (orderBy === 'updated') {
|
|
|
+ const promoted = sessionIds
|
|
|
+ .filter((id) => {
|
|
|
+ const session = list.byId[id]
|
|
|
+ return session !== undefined
|
|
|
+ && (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id])
|
|
|
+ })
|
|
|
+ .sort((a, b) => compareSessionRecency(a, b, list.byId))
|
|
|
+ if (promoted.length > 0) {
|
|
|
+ const promotedIds = new Set(promoted)
|
|
|
+ order = [...promoted, ...order.filter(id => !promotedIds.has(id))]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const updatedAt: Record<string, number> = {}
|
|
|
+ for (const id of sessionIds) {
|
|
|
+ const session = list.byId[id]
|
|
|
+ if (session !== undefined) updatedAt[id] = session.updatedAt
|
|
|
+ }
|
|
|
+ const orderChanged = previousOrder === undefined
|
|
|
+ || order.length !== previousOrder.length
|
|
|
+ || order.some((id, index) => id !== previousOrder[index])
|
|
|
+ const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length
|
|
|
+ || Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp)
|
|
|
+ return { order, updatedAt, changed: orderChanged || timestampsChanged }
|
|
|
+}
|
|
|
+
|
|
|
+/** Grouping and ordering menu; own open state so it resets with the wide chrome. */
|
|
|
+function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
|
|
|
groupBy: 'workspace' | 'flat'
|
|
|
- onPick: (mode: 'workspace' | 'flat') => void
|
|
|
+ orderBy: SessionOrderBy
|
|
|
+ onGroupPick: (mode: 'workspace' | 'flat') => void
|
|
|
+ onOrderPick: (mode: SessionOrderBy) => void
|
|
|
t: WorkspaceBrowserProps['t']
|
|
|
}) {
|
|
|
const [open, setOpen] = useState(false)
|
|
|
@@ -66,23 +160,28 @@ function GroupByMenu({ groupBy, onPick, t }: {
|
|
|
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
|
|
|
{ id: 'workspace', label: t('groupBy.workspace') },
|
|
|
{ id: 'flat', label: t('groupBy.flat') },
|
|
|
+ { type: 'separator' as const, id: 'order-by-separator' },
|
|
|
+ { type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
|
|
|
+ { id: 'manual', label: t('orderBy.manual') },
|
|
|
+ { id: 'updated', label: t('orderBy.updated') },
|
|
|
]}
|
|
|
- selectedId={groupBy}
|
|
|
+ selectedIds={[groupBy, orderBy]}
|
|
|
onSelect={(id) => {
|
|
|
- /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
|
|
|
- if (id === 'workspace' || id === 'flat') onPick(id)
|
|
|
+ if (id === 'workspace' || id === 'flat') onGroupPick(id)
|
|
|
+ else if (id === 'manual' || id === 'updated') onOrderPick(id)
|
|
|
setOpen(false)
|
|
|
}}
|
|
|
align="end"
|
|
|
+ dense
|
|
|
// Portal: the section header clips overflow, so an in-place list would
|
|
|
// be cut off at the header's bounds.
|
|
|
portal
|
|
|
anchor={(
|
|
|
- <Tooltip label={t('groupBy.label')} side="bottom" delayMs={500}>
|
|
|
+ <Tooltip label={t('viewOptions.label')} side="bottom" delayMs={500}>
|
|
|
<button
|
|
|
type="button"
|
|
|
className={clsx(css.iconButton, css.wide)}
|
|
|
- aria-label={t('groupBy.label')}
|
|
|
+ aria-label={t('viewOptions.label')}
|
|
|
onClick={() => { setOpen(v => !v) }}
|
|
|
>
|
|
|
<IconPersonalizationOutline16 />
|
|
|
@@ -95,17 +194,43 @@ function GroupByMenu({ groupBy, onPick, t }: {
|
|
|
|
|
|
/** In-flight root-row drag: source identity plus the current insert marker. */
|
|
|
interface DragState {
|
|
|
- workspaceId: WorkspaceId
|
|
|
+ /** Workspace id, or {@link UNGROUPED_KEY} for the browser-local loose-session account. */
|
|
|
+ workspaceKey: string
|
|
|
sessionId: SessionNode['id']
|
|
|
/** Row the marker sits on and which half (insert above/below it). */
|
|
|
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
|
|
|
+}
|
|
|
+
|
|
|
+/** 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' | 'insertSessionBefore' | 't'
|
|
|
+ '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
|
|
|
+ /** Shared editable orders used by Workspace groups and the flat-list account. */
|
|
|
+ recentSessionOrder: Readonly<Record<string, readonly string[]>>
|
|
|
+ /** Last update timestamps observed for one-time recent-update promotions. */
|
|
|
+ recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
|
|
|
+ /** Replace one shared order and its observed timestamps. */
|
|
|
+ syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record<string, number>) => void
|
|
|
+ /** Apply a drag to one shared order. */
|
|
|
+ setRecentSessionOrder: (workspaceKey: string, order: string[]) => void
|
|
|
/** Registry-global archive set (hidden rows). */
|
|
|
archivedSessionIds: readonly SessionNode['id'][]
|
|
|
/** Open the browser-owned rename dialog for a real Workspace group. */
|
|
|
@@ -116,127 +241,378 @@ 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
|
|
|
+ /** Session order behavior: fixed after edits, or additionally promoted by user activity. */
|
|
|
+ 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, insertSessionBefore, t,
|
|
|
+ onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
|
|
|
+ insertWorkspaceBefore, insertSessionBefore, orderBy,
|
|
|
+ workspaceExpansion, setWorkspaceExpanded,
|
|
|
+ recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
|
|
|
}: SessionTreeProps) {
|
|
|
const list = useSessions(s => s)
|
|
|
const current = list.current
|
|
|
- const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
|
|
- // Transient drag viewing state (never store-bound; order truth stays Host-side).
|
|
|
+ const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
|
|
|
+ // Transient drag marker state; the selected mode owns the resulting order.
|
|
|
const [drag, setDrag] = useState<DragState | null>(null)
|
|
|
+ const sessionDropCommitted = useRef(false)
|
|
|
+ const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
|
|
|
+ const workspaceDropCommitted = useRef(false)
|
|
|
+ const previousOrderBy = useRef(orderBy)
|
|
|
+ const nativeDragActive = drag !== null || workspaceDrag !== null
|
|
|
+ useNativeDragAcceptance(nativeDragActive)
|
|
|
const currentGroup = current === undefined
|
|
|
? undefined
|
|
|
: (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 ungroupedSessionIds = useMemo(() => {
|
|
|
+ const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
|
|
|
+ return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
|
|
|
+ }, [list, workspaces])
|
|
|
+ useEffect(() => {
|
|
|
+ if (list.phase !== 'ready') return
|
|
|
+ const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
|
|
|
+ previousOrderBy.current = orderBy
|
|
|
+ const accounts = [
|
|
|
+ ...workspaces.map(workspace => ({
|
|
|
+ key: workspace.workspaceId as string,
|
|
|
+ sessionIds: workspace.sessionIds.filter(id => list.byId[id] !== undefined),
|
|
|
+ })),
|
|
|
+ { key: UNGROUPED_KEY, sessionIds: ungroupedSessionIds },
|
|
|
+ ]
|
|
|
+ for (const { key, sessionIds } of accounts) {
|
|
|
+ const previousOrder = recentSessionOrder[key]
|
|
|
+ const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
|
|
|
+ const next = nextSessionOrderAccount({
|
|
|
+ sessionIds,
|
|
|
+ previousOrder,
|
|
|
+ previousUpdatedAt,
|
|
|
+ list,
|
|
|
+ orderBy,
|
|
|
+ sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
|
|
|
+ })
|
|
|
+ if (next.changed) {
|
|
|
+ syncRecentSessions(key, next.order.map(id => id as string), next.updatedAt)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, ungroupedSessionIds, workspaces])
|
|
|
+ const orderedWorkspaces = useMemo(() => {
|
|
|
+ return workspaces.map((workspace) => {
|
|
|
+ const stored = recentSessionOrder[workspace.workspaceId as string]
|
|
|
+ const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored)
|
|
|
+ return { ...workspace, sessionIds }
|
|
|
+ })
|
|
|
+ }, [recentSessionOrder, workspaces])
|
|
|
+ const orderedUngroupedSessionIds = useMemo(
|
|
|
+ () => reconciledSessionOrder(ungroupedSessionIds, recentSessionOrder[UNGROUPED_KEY]),
|
|
|
+ [recentSessionOrder, ungroupedSessionIds],
|
|
|
+ )
|
|
|
const groups = useMemo(
|
|
|
- () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
|
|
|
- [list, workspaces, archivedSessionIds, expandedProjects],
|
|
|
+ () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
|
|
|
+ expandedProjects,
|
|
|
+ ...(recentSessionOrder[UNGROUPED_KEY] === undefined
|
|
|
+ ? {}
|
|
|
+ : { ungroupedOrder: recentSessionOrder[UNGROUPED_KEY] }),
|
|
|
+ }),
|
|
|
+ [list, orderedWorkspaces, archivedSessionIds, expandedProjects, recentSessionOrder],
|
|
|
)
|
|
|
const now = Date.now()
|
|
|
+ const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
|
|
|
+ if (sessionDropCommitted.current) return
|
|
|
+ sessionDropCommitted.current = true
|
|
|
+ setDrag(null)
|
|
|
+ const group = groups.find(candidate => candidate.key === activeDrag.workspaceKey)
|
|
|
+ if (group === undefined) return
|
|
|
+ const targetIndex = group.sessions.findIndex(session => session.id === over.id)
|
|
|
+ if (targetIndex === -1) return
|
|
|
+ const anchor = over.half === 'before' ? over.id : group.sessions[targetIndex + 1]?.id
|
|
|
+ if (anchor === activeDrag.sessionId) return
|
|
|
+ const sourceIndex = group.sessions.findIndex(session => session.id === activeDrag.sessionId)
|
|
|
+ const anchorIndex = anchor === undefined
|
|
|
+ ? group.sessions.length
|
|
|
+ : group.sessions.findIndex(session => session.id === anchor)
|
|
|
+ if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
|
+ const accountSessionIds = activeDrag.workspaceKey === UNGROUPED_KEY
|
|
|
+ ? orderedUngroupedSessionIds
|
|
|
+ : orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceKey)?.sessionIds
|
|
|
+ if (accountSessionIds === undefined) return
|
|
|
+ const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId)
|
|
|
+ const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
|
|
+ nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
|
|
|
+ setRecentSessionOrder(activeDrag.workspaceKey, nextOrder.map(id => id as string))
|
|
|
+ if (orderBy === 'updated' || activeDrag.workspaceKey === UNGROUPED_KEY) return
|
|
|
+ insertSessionBefore(activeDrag.workspaceKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
|
|
|
+ console.warn('session reorder rejected:', reason)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ const commitWorkspaceDrag = (
|
|
|
+ activeDrag: WorkspaceDragState,
|
|
|
+ over: NonNullable<WorkspaceDragState['over']>,
|
|
|
+ ): void => {
|
|
|
+ if (workspaceDropCommitted.current) return
|
|
|
+ workspaceDropCommitted.current = true
|
|
|
+ setWorkspaceDrag(null)
|
|
|
+ const rowIndex = workspaces.findIndex(workspace => workspace.workspaceId === over.id)
|
|
|
+ if (rowIndex === -1) return
|
|
|
+ const anchor = over.half === 'before' ? over.id : workspaces[rowIndex + 1]?.workspaceId
|
|
|
+ if (anchor === activeDrag.workspaceId) return
|
|
|
+ const sourceIndex = workspaces.findIndex(workspace => workspace.workspaceId === activeDrag.workspaceId)
|
|
|
+ const anchorIndex = anchor === undefined
|
|
|
+ ? workspaces.length
|
|
|
+ : workspaces.findIndex(workspace => workspace.workspaceId === anchor)
|
|
|
+ if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
|
+ insertWorkspaceBefore(activeDrag.workspaceId, anchor).catch((reason: unknown) => {
|
|
|
+ console.warn('workspace reorder rejected:', reason)
|
|
|
+ })
|
|
|
+ }
|
|
|
+ const workspaceDropAtListStart = groups[0]?.workspaceId !== undefined
|
|
|
+ && workspaceDrag?.over?.id === groups[0].workspaceId
|
|
|
+ && workspaceDrag.over.half === 'before'
|
|
|
|
|
|
return (
|
|
|
<div className={clsx(css.treeBody, css.wide)}>
|
|
|
- <div className={css.list} role="tree" aria-label={t('section.sessions')}>
|
|
|
+ {workspaceDropAtListStart && <span className={css.listTopDropIndicator} aria-hidden="true" />}
|
|
|
+ <div
|
|
|
+ className={clsx(css.list, workspaceDropAtListStart && css.listTopDropActive)}
|
|
|
+ role="tree"
|
|
|
+ aria-label={t('section.sessions')}
|
|
|
+ >
|
|
|
{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: () => {
|
|
|
+ workspaceDropCommitted.current = false
|
|
|
+ setWorkspaceDrag({ workspaceId, over: null })
|
|
|
+ },
|
|
|
+ end: () => {
|
|
|
+ if (workspaceDrag?.over !== null && workspaceDrag?.over !== undefined) {
|
|
|
+ commitWorkspaceDrag(workspaceDrag, workspaceDrag.over)
|
|
|
+ } else {
|
|
|
+ setWorkspaceDrag(null)
|
|
|
+ }
|
|
|
+ workspaceDropCommitted.current = false
|
|
|
+ },
|
|
|
+ }
|
|
|
+ const hoverWorkspace = workspaceId === undefined
|
|
|
+ ? undefined
|
|
|
+ : (half: 'before' | 'after') => {
|
|
|
+ setWorkspaceDrag(active => active === null
|
|
|
+ ? active
|
|
|
+ : { ...active, over: { id: workspaceId, half } })
|
|
|
+ }
|
|
|
+ const dropWorkspace = workspaceId === undefined
|
|
|
+ ? undefined
|
|
|
+ : (half: 'before' | 'after') => {
|
|
|
+ if (workspaceDrag === null) return
|
|
|
+ commitWorkspaceDrag(workspaceDrag, { id: workspaceId, half })
|
|
|
+ }
|
|
|
+ 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}>
|
|
|
- <ProjectRowItem
|
|
|
- group={group}
|
|
|
- t={t}
|
|
|
- onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
|
|
|
- onCreate={() => {
|
|
|
- if (group.workspaceId !== undefined) startSession(group.workspaceId)
|
|
|
- }}
|
|
|
- actions={group.workspaceId === undefined
|
|
|
+ <div
|
|
|
+ key={group.key}
|
|
|
+ className={clsx(
|
|
|
+ css.groupSection,
|
|
|
+ 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
|
|
|
- : {
|
|
|
- rename: () => {
|
|
|
+ : (e) => {
|
|
|
+ e.preventDefault()
|
|
|
+ dropWorkspace(workspaceGroupHalf(e))
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <ProjectRowItem
|
|
|
+ group={group}
|
|
|
+ t={t}
|
|
|
+ onToggle={() => {
|
|
|
+ if (group.expanded) {
|
|
|
+ setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
|
|
|
+ }
|
|
|
+ setWorkspaceExpanded(group.key, !group.expanded)
|
|
|
+ }}
|
|
|
+ onCreate={() => {
|
|
|
+ if (group.workspaceId !== undefined) {
|
|
|
+ setWorkspaceExpanded(group.key, true)
|
|
|
+ startSession(group.workspaceId)
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ drag={workspaceDragProps}
|
|
|
+ actions={group.workspaceId === undefined
|
|
|
+ ? undefined
|
|
|
+ : {
|
|
|
+ rename: () => {
|
|
|
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
|
|
- if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
|
|
- },
|
|
|
- delete: () => {
|
|
|
+ if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
|
|
|
+ },
|
|
|
+ delete: () => {
|
|
|
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
|
|
|
- if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
|
|
+ if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ {(expandedSessionGroups.includes(group.key)
|
|
|
+ ? group.sessions
|
|
|
+ : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
|
|
|
+ ).map((node) => {
|
|
|
+ // Session drag never leaves its group. Ungrouped writes only the
|
|
|
+ // browser-local account; real Workspaces may also write Host order.
|
|
|
+ const sameGroupDrag = drag !== null && drag.workspaceKey === group.key
|
|
|
+ const dragProps = {
|
|
|
+ start: () => {
|
|
|
+ sessionDropCommitted.current = false
|
|
|
+ setDrag({ workspaceKey: group.key, sessionId: node.id, over: null })
|
|
|
},
|
|
|
- }}
|
|
|
- />
|
|
|
- {group.sessions.map((node, index) => {
|
|
|
- // Draggable: real-workspace session rows. The drag
|
|
|
- // never leaves its group — rows of other groups show no markers
|
|
|
- // and reject drops (visual movement confined to this section).
|
|
|
- const draggable = group.workspaceId !== undefined
|
|
|
- const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
|
|
|
- const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
|
|
|
- start: () => {
|
|
|
- setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
|
|
|
- },
|
|
|
- active: sameGroupDrag,
|
|
|
- marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
|
|
|
- hover: (half: 'before' | 'after') => {
|
|
|
+ active: sameGroupDrag,
|
|
|
+ marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
|
|
|
+ hover: (half: 'before' | 'after') => {
|
|
|
/* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
|
|
|
- setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
|
|
|
- },
|
|
|
- drop: (half: 'before' | 'after') => {
|
|
|
+ setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
|
|
|
+ },
|
|
|
+ drop: (half: 'before' | 'after') => {
|
|
|
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
|
|
|
- if (drag === null) return
|
|
|
- const sessions = group.sessions
|
|
|
- // Anchor = the row the insert line points at ('after' means
|
|
|
- // the next root; end-of-list omits the anchor → append).
|
|
|
- const anchor = half === 'before' ? node.id : sessions[index + 1]?.id
|
|
|
- setDrag(null)
|
|
|
- if (anchor === drag.sessionId) return
|
|
|
- // No-op when the drop lands back on the source position.
|
|
|
- const sourceIndex = sessions.findIndex(r => r.id === drag.sessionId)
|
|
|
- const anchorIndex = anchor === undefined ? sessions.length : sessions.findIndex(r => r.id === anchor)
|
|
|
- if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
|
- insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
|
|
|
- console.warn('session reorder rejected:', reason)
|
|
|
- })
|
|
|
- },
|
|
|
- end: () => { setDrag(null) },
|
|
|
- }
|
|
|
- return (
|
|
|
- <SessionNodeItem
|
|
|
- key={node.id}
|
|
|
- node={node}
|
|
|
- currentId={current}
|
|
|
- now={now}
|
|
|
- onOpen={open}
|
|
|
- onRename={onSessionRename}
|
|
|
- onFork={forkSession}
|
|
|
- onArchive={onSessionArchive}
|
|
|
- drag={dragProps}
|
|
|
- t={t}
|
|
|
- />
|
|
|
- )
|
|
|
- })}
|
|
|
- </div>
|
|
|
- ))}
|
|
|
+ if (drag === null) return
|
|
|
+ commitSessionDrag(drag, { id: node.id, half })
|
|
|
+ },
|
|
|
+ end: () => {
|
|
|
+ if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over)
|
|
|
+ else setDrag(null)
|
|
|
+ sessionDropCommitted.current = false
|
|
|
+ },
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <SessionNodeItem
|
|
|
+ key={node.id}
|
|
|
+ node={node}
|
|
|
+ currentId={current}
|
|
|
+ now={now}
|
|
|
+ onOpen={open}
|
|
|
+ onRename={onSessionRename}
|
|
|
+ onFork={forkSession}
|
|
|
+ onArchive={onSessionArchive}
|
|
|
+ drag={dragProps}
|
|
|
+ t={t}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ })}
|
|
|
+ {group.sessions.length > COLLAPSED_SESSION_LIMIT && (
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.sessionOverflowButton}
|
|
|
+ aria-expanded={expandedSessionGroups.includes(group.key)}
|
|
|
+ onClick={() => { setExpandedSessionGroups(keys => toggled(keys, group.key)) }}
|
|
|
+ >
|
|
|
+ {expandedSessionGroups.includes(group.key)
|
|
|
+ ? t('sessions.collapse')
|
|
|
+ : t('sessions.expand', { n: group.sessions.length - COLLAPSED_SESSION_LIMIT })}
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ })}
|
|
|
</div>
|
|
|
<span className={css.fade} />
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-/** The flat "In one list" body: every session a top-level row, newest-first. */
|
|
|
-function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
|
|
|
- SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
|
|
|
+/** The flat "In one list" body: every session is one draggable top-level row. */
|
|
|
+function FlatList({
|
|
|
+ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
|
|
|
+ orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
|
|
|
+}: Pick<
|
|
|
+ SessionTreeProps,
|
|
|
+ | 'useSessions'
|
|
|
+ | 'open'
|
|
|
+ | 'forkSession'
|
|
|
+ | 'onSessionRename'
|
|
|
+ | 'onSessionArchive'
|
|
|
+ | 'archivedSessionIds'
|
|
|
+ | 'orderBy'
|
|
|
+ | 'recentSessionOrder'
|
|
|
+ | 'recentSessionUpdatedAt'
|
|
|
+ | 'syncRecentSessions'
|
|
|
+ | 'setRecentSessionOrder'
|
|
|
+ | 't'
|
|
|
>) {
|
|
|
const list = useSessions(s => s)
|
|
|
- const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
|
|
|
+ const baseRows = useMemo(
|
|
|
+ () => deriveFlat(list, archivedSessionIds),
|
|
|
+ [list, archivedSessionIds],
|
|
|
+ )
|
|
|
+ const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows])
|
|
|
+ const previousOrderBy = useRef(orderBy)
|
|
|
+ useEffect(() => {
|
|
|
+ if (list.phase !== 'ready') return
|
|
|
+ const previousOrder = recentSessionOrder[FLAT_SESSION_ORDER_KEY]
|
|
|
+ const previousUpdatedAt = recentSessionUpdatedAt[FLAT_SESSION_ORDER_KEY] ?? {}
|
|
|
+ const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
|
|
|
+ previousOrderBy.current = orderBy
|
|
|
+ const next = nextSessionOrderAccount({
|
|
|
+ sessionIds,
|
|
|
+ previousOrder,
|
|
|
+ previousUpdatedAt,
|
|
|
+ list,
|
|
|
+ orderBy,
|
|
|
+ sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
|
|
|
+ })
|
|
|
+ if (next.changed) {
|
|
|
+ syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt)
|
|
|
+ }
|
|
|
+ }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions])
|
|
|
+ const rows = useMemo(() => {
|
|
|
+ const byId = new Map(baseRows.map(row => [row.id, row]))
|
|
|
+ return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY])
|
|
|
+ .flatMap((id) => {
|
|
|
+ const row = byId.get(id)
|
|
|
+ return row === undefined ? [] : [row]
|
|
|
+ })
|
|
|
+ }, [baseRows, recentSessionOrder, sessionIds])
|
|
|
+ const [drag, setDrag] = useState<DragState | null>(null)
|
|
|
+ const dropCommitted = useRef(false)
|
|
|
+ useNativeDragAcceptance(drag !== null)
|
|
|
+ const commitDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
|
|
|
+ if (dropCommitted.current) return
|
|
|
+ dropCommitted.current = true
|
|
|
+ setDrag(null)
|
|
|
+ const targetIndex = rows.findIndex(row => row.id === over.id)
|
|
|
+ if (targetIndex === -1) return
|
|
|
+ const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id
|
|
|
+ if (anchor === activeDrag.sessionId) return
|
|
|
+ const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId)
|
|
|
+ const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor)
|
|
|
+ if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
|
|
|
+ const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId)
|
|
|
+ const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
|
|
|
+ nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
|
|
|
+ setRecentSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string))
|
|
|
+ }
|
|
|
const now = Date.now()
|
|
|
return (
|
|
|
<div className={clsx(css.treeBody, css.wide)}>
|
|
|
@@ -244,19 +620,42 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
|
|
|
{rows.length === 0 && (
|
|
|
<div className={css.empty}>{t('empty.none')}</div>
|
|
|
)}
|
|
|
- {rows.map(node => (
|
|
|
- <SessionNodeItem
|
|
|
- key={node.id}
|
|
|
- node={node}
|
|
|
- currentId={list.current}
|
|
|
- now={now}
|
|
|
- onOpen={open}
|
|
|
- onRename={onSessionRename}
|
|
|
- onFork={forkSession}
|
|
|
- onArchive={onSessionArchive}
|
|
|
- t={t}
|
|
|
- />
|
|
|
- ))}
|
|
|
+ {rows.map((node) => {
|
|
|
+ const active = drag !== null
|
|
|
+ return (
|
|
|
+ <SessionNodeItem
|
|
|
+ key={node.id}
|
|
|
+ node={node}
|
|
|
+ currentId={list.current}
|
|
|
+ now={now}
|
|
|
+ onOpen={open}
|
|
|
+ onRename={onSessionRename}
|
|
|
+ onFork={forkSession}
|
|
|
+ onArchive={onSessionArchive}
|
|
|
+ flat
|
|
|
+ drag={{
|
|
|
+ start: () => {
|
|
|
+ dropCommitted.current = false
|
|
|
+ setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null })
|
|
|
+ },
|
|
|
+ active,
|
|
|
+ marker: active && drag.over?.id === node.id ? drag.over.half : null,
|
|
|
+ hover: (half) => {
|
|
|
+ setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } })
|
|
|
+ },
|
|
|
+ drop: (half) => {
|
|
|
+ if (drag !== null) commitDrag(drag, { id: node.id, half })
|
|
|
+ },
|
|
|
+ end: () => {
|
|
|
+ if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over)
|
|
|
+ else setDrag(null)
|
|
|
+ dropCommitted.current = false
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ t={t}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ })}
|
|
|
</div>
|
|
|
<span className={css.fade} />
|
|
|
</div>
|
|
|
@@ -352,6 +751,7 @@ export function WorkspaceBrowser({
|
|
|
forkSession,
|
|
|
renameWorkspace,
|
|
|
deleteWorkspace,
|
|
|
+ insertWorkspaceBefore,
|
|
|
archiveSession,
|
|
|
insertSessionBefore,
|
|
|
createWorkspace,
|
|
|
@@ -362,14 +762,28 @@ export function WorkspaceBrowser({
|
|
|
t,
|
|
|
}: WorkspaceBrowserProps) {
|
|
|
const workspaces = useWorkspaces(state => state.items)
|
|
|
+ const workspacePhase = useWorkspaces(state => state.phase)
|
|
|
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
|
|
|
// Live occupancy of this surface's directory-flow hole (the same source the
|
|
|
// flow reads): a composition without a picking affordance can add nothing.
|
|
|
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
|
|
|
const groupBy = useStore(s => s.groupBy)
|
|
|
+ const orderBy = useStore(s => s.orderBy)
|
|
|
+ const workspaceExpansion = useStore(s => s.workspaceExpansion)
|
|
|
+ const recentSessionOrder = useStore(s => s.recentSessionOrder)
|
|
|
+ const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt)
|
|
|
+ useEffect(() => {
|
|
|
+ if (workspacePhase !== 'ready') return
|
|
|
+ actions.retainWorkspaceKeys([
|
|
|
+ UNGROUPED_KEY,
|
|
|
+ FLAT_SESSION_ORDER_KEY,
|
|
|
+ ...workspaces.map(workspace => workspace.workspaceId as string),
|
|
|
+ ])
|
|
|
+ }, [actions.retainWorkspaceKeys, workspacePhase, workspaces])
|
|
|
// 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: '',
|
|
|
@@ -377,6 +791,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).
|
|
|
@@ -397,6 +812,23 @@ 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 onClick = (event: MouseEvent): void => {
|
|
|
+ if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
|
|
|
+ searchInput.current?.blur()
|
|
|
+ if (normalizedQuery !== '') return
|
|
|
+ setSearchExpanded(false)
|
|
|
+ }
|
|
|
+ document.addEventListener('click', onClick)
|
|
|
+ return () => { document.removeEventListener('click', onClick) }
|
|
|
+ }, [normalizedQuery, wide, searchExpanded])
|
|
|
+
|
|
|
useEffect(() => {
|
|
|
if (normalizedQuery === '') {
|
|
|
setRemoteSearch({ query: '', status: 'idle', items: [], hasMore: false })
|
|
|
@@ -544,29 +976,96 @@ export function WorkspaceBrowser({
|
|
|
<div className={clsx(css.root, !wide && css.rail)}>
|
|
|
<div className={css.sectionHeader}>
|
|
|
{wide && (
|
|
|
- <span className={clsx(css.sectionLabel, css.wide)}>
|
|
|
+ <span className={clsx(css.sectionLabel, css.wide, searchExpanded && css.sectionLabelHidden)}>
|
|
|
{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
|
|
|
</span>
|
|
|
)}
|
|
|
- {wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(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')}
|
|
|
+ {wide && (
|
|
|
+ <div className={clsx(css.searchSlot, searchExpanded && css.searchSlotExpanded)}>
|
|
|
+ <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')} side="bottom" delayMs={500} disabled={searchExpanded}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.searchButton}
|
|
|
+ aria-label={t('search.sessions.aria')}
|
|
|
+ aria-expanded={searchExpanded}
|
|
|
+ onClick={() => {
|
|
|
+ setWsPickerOpen(false)
|
|
|
+ setSearchExpanded(true)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <IconSearchOutline16 size={searchExpanded ? 11 : 14} />
|
|
|
+ </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={orderBy}
|
|
|
+ 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}
|
|
|
@@ -586,42 +1085,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. */}
|
|
|
@@ -644,7 +1124,13 @@ export function WorkspaceBrowser({
|
|
|
<FlatList
|
|
|
useSessions={useSessions} open={open} forkSession={forkSession}
|
|
|
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
|
|
|
- archivedSessionIds={archivedSessionIds} t={t}
|
|
|
+ archivedSessionIds={archivedSessionIds}
|
|
|
+ orderBy={orderBy}
|
|
|
+ recentSessionOrder={recentSessionOrder}
|
|
|
+ recentSessionUpdatedAt={recentSessionUpdatedAt}
|
|
|
+ syncRecentSessions={actions.syncRecentSessions}
|
|
|
+ setRecentSessionOrder={actions.setRecentSessionOrder}
|
|
|
+ t={t}
|
|
|
/>
|
|
|
)
|
|
|
: (
|
|
|
@@ -654,10 +1140,18 @@ export function WorkspaceBrowser({
|
|
|
onSessionArchive={onSessionArchive}
|
|
|
forkSession={forkSession}
|
|
|
workspaces={workspaces}
|
|
|
+ workspaceExpansion={workspaceExpansion}
|
|
|
+ setWorkspaceExpanded={actions.setWorkspaceExpanded}
|
|
|
+ recentSessionOrder={recentSessionOrder}
|
|
|
+ recentSessionUpdatedAt={recentSessionUpdatedAt}
|
|
|
+ syncRecentSessions={actions.syncRecentSessions}
|
|
|
+ setRecentSessionOrder={actions.setRecentSessionOrder}
|
|
|
archivedSessionIds={archivedSessionIds}
|
|
|
startSession={startSession}
|
|
|
open={open}
|
|
|
+ insertWorkspaceBefore={insertWorkspaceBefore}
|
|
|
insertSessionBefore={insertSessionBefore}
|
|
|
+ orderBy={orderBy}
|
|
|
t={t}
|
|
|
onRenameRequest={(workspaceId, currentTitle) => {
|
|
|
setRenameTarget({ workspaceId, currentTitle })
|