diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index dd3cd9661c..060e8d115b 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -1,6 +1,6 @@ /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar foot trigger row + centered 1080x700 modal panel. The trigger uses the - sidebar's 38px wide row / 36px rail circle rhythm; the + sidebar's 34px compact row / 36px rail circle rhythm; the panel is a two-column layout — 188px nav rail + content column with a 54px header and the 24px-padded options area. */ @@ -10,10 +10,10 @@ display: flex; align-items: center; gap: 8px; - width: calc(100% - 8px); - height: 38px; - margin: 4px 4px 4px; - padding: 8px 2px 8px 10px; + width: calc(100% + 8px); + height: 34px; + margin: 4px -4px 4px; + padding: 6px 2px 6px 10px; box-sizing: border-box; border: none; border-radius: 12px; diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 944c6917d1..1431096afe 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -38,6 +38,19 @@ background: var(--dsw-alias-interactive-bg-hover); } +.viewOptionLabel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; +} + +.viewOptionCheck { + flex: none; + color: var(--dsw-alias-label-primary); +} + /* Section header: title, an inline search control, and the two trailing actions. Expanding search collapses the action cluster and takes its room. */ .sectionHeader { @@ -56,6 +69,7 @@ } .root:not(.rail) .sectionHeader { + margin-top: 2px; margin-right: -4px; } @@ -66,6 +80,23 @@ overflow: hidden; white-space: nowrap; line-height: 20px; + opacity: 1; + visibility: visible; + transition: + max-width 180ms var(--ds-ease-in-out), + margin-right 180ms var(--ds-ease-in-out), + opacity 120ms var(--ds-ease-in-out), + transform 180ms var(--ds-ease-in-out), + visibility 0s linear; +} + +.sectionLabelHidden { + max-width: 0; + margin-right: -4px; + opacity: 0; + transform: translateX(-4px); + visibility: hidden; + transition-delay: 0s, 0s, 0s, 0s, 180ms; } .searchSlot { @@ -84,7 +115,7 @@ .searchSlotExpanded { max-width: 100%; - padding-left: 4px; + padding-left: 0; } .headerActions { @@ -138,7 +169,9 @@ } .searchExpanded { - height: 26px; + width: calc(100% + 4px); + height: 34px; + margin-inline: -2px; padding: 0 4px 0 0; border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; @@ -162,8 +195,8 @@ } .searchExpanded .searchButton { - width: 26px; - height: 26px; + width: 28px; + height: 34px; } .searchButton:hover { @@ -204,8 +237,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 18px; - height: 18px; + width: 24px; + height: 24px; border: none; border-radius: 50%; padding: 0; @@ -214,6 +247,10 @@ color: var(--dsw-alias-label-secondary); } +.clearButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Rail variant (own .rail class from the wide owner prop — the region never reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ @@ -367,7 +404,7 @@ .sessionOverflowButton { width: 100%; - height: 30px; + height: 28px; border: none; border-radius: 8px; padding: 0 12px 0 28px; @@ -385,8 +422,6 @@ .sessionOverflowButton:hover { background: transparent; color: var(--dsw-alias-label-secondary); - text-decoration: underline; - text-underline-offset: 2px; } .empty { @@ -438,6 +473,7 @@ } .search, + .sectionLabel, .searchSlot, .searchInput, .headerActions { diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 5c27af999f..f1dd5dd8aa 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -12,11 +12,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCloseFill14, IconPersonalizationOutline16, + Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16, 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, SessionOrderBy } from './tree.ts' @@ -37,6 +37,8 @@ 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> = Object.freeze({}) +const EMPTY_RECENT_SESSION_ORDER: Readonly> = Object.freeze({}) +const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly>>> = Object.freeze({}) /** Keep controlled input and RPC payload inside the session.search wire contract. */ function sanitizeSearchQuery(value: string): string { @@ -54,6 +56,33 @@ function toggled(list: readonly string[], key: string): string[] { return list.includes(key) ? list.filter(k => k !== key) : [...list, key] } +/** 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() + 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 +} + /** Grouping and ordering menu; own open state so it resets with the wide chrome. */ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { groupBy: 'workspace' | 'flat' @@ -63,23 +92,27 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { t: WorkspaceBrowserProps['t'] }) { const [open, setOpen] = useState(false) + const optionLabel = (label: string, selected: boolean) => ( + + {label} + {selected && } + + ) return ( { setOpen(false) }} items={[ { type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, - { id: 'workspace', label: t('groupBy.workspace') }, - { id: 'flat', label: t('groupBy.flat') }, + { id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') }, + { id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') }, { type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, - { id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' }, - { id: 'created', label: t('orderBy.created') }, - { id: 'updated', label: t('orderBy.updated') }, + { id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' }, + { id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') }, ]} - selectedIds={[groupBy, orderBy]} onSelect={(id) => { if (id === 'workspace' || id === 'flat') onGroupPick(id) - else if (id === 'manual' || id === 'created' || id === 'updated') onOrderPick(id) + else if (id === 'manual' || id === 'updated') onOrderPick(id) setOpen(false) }} align="end" @@ -133,6 +166,14 @@ type SessionTreeProps = Pick< workspaceExpansion: Readonly> /** Persist one Workspace group's zero-or-five-session state. */ setWorkspaceExpanded: (key: string, expanded: boolean) => void + /** Editable orders used by recent-update mode. */ + recentSessionOrder: Readonly> + /** Last update timestamps observed by recent-update mode. */ + recentSessionUpdatedAt: Readonly>>> + /** Replace one recent-mode order and its observed timestamps. */ + syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record) => void + /** Apply a manual drag inside one recent-mode 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. */ @@ -143,7 +184,7 @@ 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 visual order; only manual mode exposes durable Session dragging. */ + /** Session visual order; manual mode drags durable order, updated mode drags its view order. */ orderBy: SessionOrderBy } @@ -152,14 +193,34 @@ function SessionTree({ useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, - workspaceExpansion, setWorkspaceExpanded, t, + workspaceExpansion, setWorkspaceExpanded, + recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t, }: SessionTreeProps) { const list = useSessions(s => s) const current = list.current const [expandedSessionGroups, setExpandedSessionGroups] = useState([]) - // Transient drag viewing state (never store-bound; order truth stays Host-side). + // Transient drag marker state; the selected mode owns the resulting order. const [drag, setDrag] = useState(null) + const sessionDropCommitted = useRef(false) const [workspaceDrag, setWorkspaceDrag] = useState(null) + const sessionDragging = drag !== null + useEffect(() => { + if (!sessionDragging) return + // Row hover still owns the insertion marker. Accept the native drag at + // document level so releasing outside the list is not rendered as a + // rejected drop before dragend commits that last marker. + 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) + } + }, [sessionDragging]) const currentGroup = current === undefined ? undefined : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) @@ -172,11 +233,79 @@ function SessionTree({ () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key), [workspaceExpansion], ) + useEffect(() => { + if (orderBy !== 'updated' || list.phase !== 'ready') return + for (const workspace of workspaces) { + const key = workspace.workspaceId as string + const sessionIds = workspace.sessionIds.filter(id => list.byId[id] !== undefined) + const previousOrder = recentSessionOrder[key] + const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {} + let nextOrder = reconciledSessionOrder(sessionIds, previousOrder) + if (previousOrder === undefined) { + nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId)) + } else { + const promoted = sessionIds + .filter((id) => previousUpdatedAt[id] === undefined || list.byId[id]!.updatedAt > previousUpdatedAt[id]!) + .sort((a, b) => compareSessionRecency(a, b, list.byId)) + if (promoted.length > 0) { + const promotedIds = new Set(promoted) + nextOrder = [...promoted, ...nextOrder.filter(id => !promotedIds.has(id))] + } + } + const nextUpdatedAt: Record = {} + for (const id of sessionIds) nextUpdatedAt[id] = list.byId[id]!.updatedAt + const orderChanged = previousOrder === undefined + || nextOrder.length !== previousOrder.length + || nextOrder.some((id, index) => id !== previousOrder[index]) + const timestampsChanged = Object.keys(nextUpdatedAt).length !== Object.keys(previousUpdatedAt).length + || Object.entries(nextUpdatedAt).some(([id, updatedAt]) => previousUpdatedAt[id] !== updatedAt) + if (orderChanged || timestampsChanged) { + syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt) + } + } + }, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, workspaces]) + const orderedWorkspaces = useMemo(() => { + if (orderBy !== 'updated') return workspaces + return workspaces.map((workspace) => { + const stored = recentSessionOrder[workspace.workspaceId as string] + const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored) + if (stored === undefined) sessionIds.sort((a, b) => compareSessionRecency(a, b, list.byId)) + return { ...workspace, sessionIds } + }) + }, [list.byId, orderBy, recentSessionOrder, workspaces]) const groups = useMemo( - () => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), - [list, workspaces, archivedSessionIds, expandedProjects, orderBy], + () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'), + [list, orderedWorkspaces, archivedSessionIds, expandedProjects], ) const now = Date.now() + const commitSessionDrag = (activeDrag: DragState, over: NonNullable): void => { + if (sessionDropCommitted.current) return + sessionDropCommitted.current = true + setDrag(null) + const group = groups.find(candidate => candidate.workspaceId === activeDrag.workspaceId) + 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 + if (orderBy === 'updated') { + const account = orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceId) + if (account === undefined) return + const nextOrder = account.sessionIds.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.workspaceId as string, nextOrder.map(id => id as string)) + return + } + insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => { + console.warn('session reorder rejected:', reason) + }) + } return (
@@ -271,14 +400,15 @@ function SessionTree({ {(expandedSessionGroups.includes(group.key) ? group.sessions : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) - ).map((node, index) => { + ).map((node) => { // 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 && orderBy === 'manual' + const draggable = group.workspaceId !== undefined const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId const dragProps = !draggable || group.workspaceId === undefined ? undefined : { start: () => { + sessionDropCommitted.current = false setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) }, active: sameGroupDrag, @@ -290,21 +420,13 @@ function SessionTree({ 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) - }) + commitSessionDrag(drag, { id: node.id, half }) + }, + end: () => { + if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over) + else setDrag(null) + sessionDropCommitted.current = false }, - end: () => { setDrag(null) }, } return ( s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) + const recentSessionOrder = useStore(s => s.recentSessionOrder ?? EMPTY_RECENT_SESSION_ORDER) + const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt ?? EMPTY_RECENT_SESSION_UPDATED_AT) // 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('') @@ -532,12 +656,12 @@ export function WorkspaceBrowser({ const onClick = (event: MouseEvent): void => { if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return searchInput.current?.blur() - setQuery('') + if (query !== '') return setSearchExpanded(false) } document.addEventListener('click', onClick) return () => { document.removeEventListener('click', onClick) } - }, [wide, searchExpanded]) + }, [query, wide, searchExpanded]) useEffect(() => { if (normalizedQuery === '') { @@ -686,7 +810,7 @@ export function WorkspaceBrowser({
{wide && ( - + {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')} )} @@ -846,6 +970,10 @@ export function WorkspaceBrowser({ workspaces={workspaces} workspaceExpansion={workspaceExpansion} setWorkspaceExpanded={actions.setWorkspaceExpanded} + recentSessionOrder={recentSessionOrder} + recentSessionUpdatedAt={recentSessionUpdatedAt} + syncRecentSessions={actions.syncRecentSessions} + setRecentSessionOrder={actions.setRecentSessionOrder} archivedSessionIds={archivedSessionIds} startSession={startSession} open={open} diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index c816326157..e43c977a40 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -15,7 +15,6 @@ export const zh = { 'groupBy.flat': '单列表', 'orderBy.label': '排序方式', 'orderBy.manual': '手动排序', - 'orderBy.created': '创建时间', 'orderBy.updated': '最近更新', 'sessions.expand': '展开其余 {n} 个会话', 'sessions.collapse': '收起', @@ -84,7 +83,6 @@ export const en = { 'groupBy.flat': 'In one list', 'orderBy.label': 'Order by', 'orderBy.manual': 'Manual', - 'orderBy.created': 'Date created', 'orderBy.updated': 'Last updated', 'sessions.expand': 'Show {n} more sessions', 'sessions.collapse': 'Show less', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 902d8e0327..548407fd40 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -1,5 +1,5 @@ -/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px - single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps +/* Tree rows: project 34px, session 32px, radius 8, indent step 22px + (16px slot + 6px gap). Hover swaps are pure CSS: project folder -> chevron + action buttons; session time -> ellipsis button. */ @@ -29,11 +29,11 @@ flex-direction: column; align-items: stretch; width: 100%; - min-height: 62px; + min-height: 48px; box-sizing: border-box; border: none; border-radius: 8px; - padding: 7px 8px; + padding: 4px 8px; background: transparent; cursor: pointer; text-align: left; @@ -64,9 +64,16 @@ line-height: 20px; } +.searchResultMeta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + margin-left: 20px; +} + .searchResultWorkspace, .searchResultSnippet { - margin-left: 20px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -75,16 +82,20 @@ } .searchResultWorkspace { + flex: none; + max-width: 40%; color: var(--dsw-alias-label-tertiary); } .searchResultSnippet { + flex: 1; + min-width: 0; color: var(--dsw-alias-label-secondary); } /* Compact one-line Workspace row after removing the session-count subtitle. */ .projectRow { - height: 36px; + height: 34px; align-items: center; box-sizing: border-box; } @@ -95,7 +106,7 @@ /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ .sessionRow { - height: 34px; + height: 32px; gap: 0; /* Mount fade: session rows appear by unfolding a group (or the tree mounting). Stable row keys keep already-visible rows from replaying it. */ diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 05f36cf959..71998f2b6b 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -317,10 +317,12 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { {result.title} - {result.workspace} - {result.snippet !== undefined && ( - {result.snippet} - )} + + {result.workspace} + {result.snippet !== undefined && ( + {result.snippet} + )} + ) } diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts index 9a17ba0c15..e5334b0e4f 100644 --- a/packages/client/ui-workspace/src/client/stores.ts +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -9,8 +9,8 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run /** Session-list grouping mode: workspace sections or one flat recency list. */ export type WorkspaceGroupBy = 'workspace' | 'flat' -/** Session order: durable Workspace order or a derived timestamp order. */ -export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' +/** Session order: durable Workspace order or an activity-promoted editable order. */ +export type WorkspaceOrderBy = 'manual' | 'updated' /** Workspace browser viewing state persisted across surface remounts and reloads. */ type WorkspaceViewState = { @@ -18,6 +18,10 @@ type WorkspaceViewState = { orderBy: WorkspaceOrderBy /** Explicit zero-or-five-session state keyed by Workspace group identity. */ workspaceExpansion: Record + /** Editable per-Workspace order used by recent-update mode. */ + recentSessionOrder: Record + /** Last observed update timestamps used to detect promotion events. */ + recentSessionUpdatedAt: Record> } /** @@ -28,6 +32,13 @@ type WorkspaceViewActions = { setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void + syncRecentSessions: ( + draft: WorkspaceViewState, + workspaceKey: string, + order: string[], + updatedAt: Record, + ) => void + setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void } /** @@ -36,12 +47,25 @@ type WorkspaceViewActions = { */ export function createWorkspaceViewStore(): EngineStoreHandle { return defineStore({ - init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }), - persist: 'dsh.workspace.view.v3', + init: (): WorkspaceViewState => ({ + groupBy: 'workspace', + orderBy: 'manual', + workspaceExpansion: {}, + recentSessionOrder: {}, + recentSessionUpdatedAt: {}, + }), + persist: 'dsh.workspace.view.v4', 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 }, + syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record) => { + d.recentSessionOrder[workspaceKey] = order + d.recentSessionUpdatedAt[workspaceKey] = updatedAt + }, + setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => { + d.recentSessionOrder[workspaceKey] = order + }, }, }) } diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 5120e31931..553742ef90 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -34,7 +34,7 @@ export interface SessionNode { } /** Session order selected by the Workspace browser. */ -export type SessionOrderBy = 'manual' | 'created' | 'updated' +export type SessionOrderBy = 'manual' | 'updated' /** One workspace group section: header row facts + visible top-level session rows. */ export interface GroupNode { @@ -108,14 +108,8 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { return a.id < b.id ? -1 : 1 } -/** Newest-created first, id as the deterministic tiebreak. */ -function byCreation(a: SessionSummary, b: SessionSummary): number { - if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt - return a.id < b.id ? -1 : 1 -} - -function sortSessions(sessions: SessionSummary[], orderBy: Exclude): void { - sessions.sort(orderBy === 'created' ? byCreation : byRecency) +function sortSessions(sessions: SessionSummary[]): void { + sessions.sort(byRecency) } /** @@ -150,7 +144,7 @@ function buildGroup( orderBy: SessionOrderBy, ): Group { const sessions = [...members] - if (orderBy !== 'manual') sortSessions(sessions, orderBy) + if (orderBy !== 'manual') sortSessions(sessions) return { key, workspaceId, cwd, createdAt, label, sessions } }