feat(client): refine workspace sidebar interactions

This commit is contained in:
_Kerman
2026-08-11 15:25:11 +08:00
parent d86ecf7b29
commit 3f315a0134
8 changed files with 267 additions and 74 deletions
@@ -1,6 +1,6 @@
/* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar /* Settings shell (figma 501:29904 mask context / 501:29947 panel): sidebar
foot trigger row + centered 1080x700 modal panel. The trigger uses the 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 panel is a two-column layout — 188px nav rail + content column with a
54px header and the 24px-padded options area. */ 54px header and the 24px-padded options area. */
@@ -10,10 +10,10 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: calc(100% - 8px); width: calc(100% + 8px);
height: 38px; height: 34px;
margin: 4px 4px 4px; margin: 4px -4px 4px;
padding: 8px 2px 8px 10px; padding: 6px 2px 6px 10px;
box-sizing: border-box; box-sizing: border-box;
border: none; border: none;
border-radius: 12px; border-radius: 12px;
@@ -38,6 +38,19 @@
background: var(--dsw-alias-interactive-bg-hover); 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 /* Section header: title, an inline search control, and the two trailing
actions. Expanding search collapses the action cluster and takes its room. */ actions. Expanding search collapses the action cluster and takes its room. */
.sectionHeader { .sectionHeader {
@@ -56,6 +69,7 @@
} }
.root:not(.rail) .sectionHeader { .root:not(.rail) .sectionHeader {
margin-top: 2px;
margin-right: -4px; margin-right: -4px;
} }
@@ -66,6 +80,23 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
line-height: 20px; 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 { .searchSlot {
@@ -84,7 +115,7 @@
.searchSlotExpanded { .searchSlotExpanded {
max-width: 100%; max-width: 100%;
padding-left: 4px; padding-left: 0;
} }
.headerActions { .headerActions {
@@ -138,7 +169,9 @@
} }
.searchExpanded { .searchExpanded {
height: 26px; width: calc(100% + 4px);
height: 34px;
margin-inline: -2px;
padding: 0 4px 0 0; padding: 0 4px 0 0;
border: 1px solid var(--dsw-alias-border-l2); border: 1px solid var(--dsw-alias-border-l2);
border-radius: 10px; border-radius: 10px;
@@ -162,8 +195,8 @@
} }
.searchExpanded .searchButton { .searchExpanded .searchButton {
width: 26px; width: 28px;
height: 26px; height: 34px;
} }
.searchButton:hover { .searchButton:hover {
@@ -204,8 +237,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 18px; width: 24px;
height: 18px; height: 24px;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
padding: 0; padding: 0;
@@ -214,6 +247,10 @@
color: var(--dsw-alias-label-secondary); 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 /* 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 reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */ circles matching the shell's rail rhythm. */
@@ -367,7 +404,7 @@
.sessionOverflowButton { .sessionOverflowButton {
width: 100%; width: 100%;
height: 30px; height: 28px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 0 12px 0 28px; padding: 0 12px 0 28px;
@@ -385,8 +422,6 @@
.sessionOverflowButton:hover { .sessionOverflowButton:hover {
background: transparent; background: transparent;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
text-decoration: underline;
text-underline-offset: 2px;
} }
.empty { .empty {
@@ -438,6 +473,7 @@
} }
.search, .search,
.sectionLabel,
.searchSlot, .searchSlot,
.searchInput, .searchInput,
.headerActions { .headerActions {
@@ -12,11 +12,11 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
Button, IconCloseFill14, IconPersonalizationOutline16, Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { import type {
SessionSearchResultItem, WorkspaceId, WorkspaceView, SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserProps } from './contract/slots.ts' import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode, SessionOrderBy } from './tree.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. */ /** Session rows visible per Workspace before the local overflow control. */
const COLLAPSED_SESSION_LIMIT = 5 const COLLAPSED_SESSION_LIMIT = 5
const EMPTY_WORKSPACE_EXPANSION: Readonly<Record<string, boolean>> = Object.freeze({}) const EMPTY_WORKSPACE_EXPANSION: Readonly<Record<string, boolean>> = Object.freeze({})
const EMPTY_RECENT_SESSION_ORDER: Readonly<Record<string, readonly string[]>> = Object.freeze({})
const EMPTY_RECENT_SESSION_UPDATED_AT: Readonly<Record<string, Readonly<Record<string, number>>>> = Object.freeze({})
/** 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 {
@@ -54,6 +56,33 @@ function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter(k => k !== key) : [...list, key] 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<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
}
/** Grouping and ordering menu; own open state so it resets with the wide chrome. */ /** Grouping and ordering menu; own open state so it resets with the wide chrome. */
function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
groupBy: 'workspace' | 'flat' groupBy: 'workspace' | 'flat'
@@ -63,23 +92,27 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
t: WorkspaceBrowserProps['t'] t: WorkspaceBrowserProps['t']
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const optionLabel = (label: string, selected: boolean) => (
<span className={css.viewOptionLabel}>
<span>{label}</span>
{selected && <IconCheckOutline16 className={css.viewOptionCheck} />}
</span>
)
return ( return (
<Menu <Menu
open={open} open={open}
onClose={() => { setOpen(false) }} onClose={() => { setOpen(false) }}
items={[ items={[
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') }, { type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
{ id: 'workspace', label: t('groupBy.workspace') }, { id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') },
{ id: 'flat', label: t('groupBy.flat') }, { id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') },
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') }, { type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
{ id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' }, { id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' },
{ id: 'created', label: t('orderBy.created') }, { id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') },
{ id: 'updated', label: t('orderBy.updated') },
]} ]}
selectedIds={[groupBy, orderBy]}
onSelect={(id) => { onSelect={(id) => {
if (id === 'workspace' || id === 'flat') onGroupPick(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) setOpen(false)
}} }}
align="end" align="end"
@@ -133,6 +166,14 @@ type SessionTreeProps = Pick<
workspaceExpansion: Readonly<Record<string, boolean>> workspaceExpansion: Readonly<Record<string, boolean>>
/** Persist one Workspace group's zero-or-five-session state. */ /** Persist one Workspace group's zero-or-five-session state. */
setWorkspaceExpanded: (key: string, expanded: boolean) => void setWorkspaceExpanded: (key: string, expanded: boolean) => void
/** Editable orders used by recent-update mode. */
recentSessionOrder: Readonly<Record<string, readonly string[]>>
/** Last update timestamps observed by recent-update mode. */
recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
/** Replace one recent-mode order and its observed timestamps. */
syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record<string, number>) => void
/** Apply a manual drag inside one recent-mode order. */
setRecentSessionOrder: (workspaceKey: string, order: string[]) => void
/** Registry-global archive set (hidden rows). */ /** Registry-global archive set (hidden rows). */
archivedSessionIds: readonly SessionNode['id'][] archivedSessionIds: readonly SessionNode['id'][]
/** Open the browser-owned rename dialog for a real Workspace group. */ /** 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 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
/** 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 orderBy: SessionOrderBy
} }
@@ -152,14 +193,34 @@ function SessionTree({
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds, useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
insertWorkspaceBefore, insertSessionBefore, orderBy, insertWorkspaceBefore, insertSessionBefore, orderBy,
workspaceExpansion, setWorkspaceExpanded, t, workspaceExpansion, setWorkspaceExpanded,
recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
}: SessionTreeProps) { }: SessionTreeProps) {
const list = useSessions(s => s) const list = useSessions(s => s)
const current = list.current const current = list.current
const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([]) const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
// 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<DragState | null>(null) const [drag, setDrag] = useState<DragState | null>(null)
const sessionDropCommitted = useRef(false)
const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null) const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(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 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)
@@ -172,11 +233,79 @@ function SessionTree({
() => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key), () => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
[workspaceExpansion], [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<string, number> = {}
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( const groups = useMemo(
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }, orderBy), () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'),
[list, workspaces, archivedSessionIds, expandedProjects, orderBy], [list, orderedWorkspaces, archivedSessionIds, expandedProjects],
) )
const now = Date.now() 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.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 ( return (
<div className={clsx(css.treeBody, css.wide)}> <div className={clsx(css.treeBody, css.wide)}>
@@ -271,14 +400,15 @@ function SessionTree({
{(expandedSessionGroups.includes(group.key) {(expandedSessionGroups.includes(group.key)
? group.sessions ? group.sessions
: group.sessions.slice(0, COLLAPSED_SESSION_LIMIT) : group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
).map((node, index) => { ).map((node) => {
// Draggable: real-workspace session rows. The drag // Draggable: real-workspace session rows. The drag
// never leaves its group — rows of other groups show no markers // never leaves its group — rows of other groups show no markers
// and reject drops (visual movement confined to this section). // 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 sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : { const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
start: () => { start: () => {
sessionDropCommitted.current = false
setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
}, },
active: sameGroupDrag, active: sameGroupDrag,
@@ -290,21 +420,13 @@ function SessionTree({
drop: (half: 'before' | 'after') => { drop: (half: 'before' | 'after') => {
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
if (drag === null) return if (drag === null) return
const sessions = group.sessions commitSessionDrag(drag, { id: node.id, half })
// Anchor = the row the insert line points at ('after' means },
// the next root; end-of-list omits the anchor → append). end: () => {
const anchor = half === 'before' ? node.id : sessions[index + 1]?.id if (drag?.over !== null && drag?.over !== undefined) commitSessionDrag(drag, drag.over)
setDrag(null) else setDrag(null)
if (anchor === drag.sessionId) return sessionDropCommitted.current = false
// 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 ( return (
<SessionNodeItem <SessionNodeItem
@@ -490,6 +612,8 @@ export function WorkspaceBrowser({
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
// HMR can retain the preceding view-store instance until the slot remounts. // HMR can retain the preceding view-store instance until the slot remounts.
const workspaceExpansion = useStore(s => s.workspaceExpansion ?? EMPTY_WORKSPACE_EXPANSION) const workspaceExpansion = useStore(s => 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 // 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('')
@@ -532,12 +656,12 @@ export function WorkspaceBrowser({
const onClick = (event: MouseEvent): void => { const onClick = (event: MouseEvent): void => {
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
searchInput.current?.blur() searchInput.current?.blur()
setQuery('') if (query !== '') return
setSearchExpanded(false) setSearchExpanded(false)
} }
document.addEventListener('click', onClick) document.addEventListener('click', onClick)
return () => { document.removeEventListener('click', onClick) } return () => { document.removeEventListener('click', onClick) }
}, [wide, searchExpanded]) }, [query, wide, searchExpanded])
useEffect(() => { useEffect(() => {
if (normalizedQuery === '') { if (normalizedQuery === '') {
@@ -686,7 +810,7 @@ export function WorkspaceBrowser({
<div className={clsx(css.root, !wide && css.rail)}> <div className={clsx(css.root, !wide && css.rail)}>
<div className={css.sectionHeader}> <div className={css.sectionHeader}>
{wide && ( {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')} {groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
</span> </span>
)} )}
@@ -846,6 +970,10 @@ export function WorkspaceBrowser({
workspaces={workspaces} workspaces={workspaces}
workspaceExpansion={workspaceExpansion} workspaceExpansion={workspaceExpansion}
setWorkspaceExpanded={actions.setWorkspaceExpanded} setWorkspaceExpanded={actions.setWorkspaceExpanded}
recentSessionOrder={recentSessionOrder}
recentSessionUpdatedAt={recentSessionUpdatedAt}
syncRecentSessions={actions.syncRecentSessions}
setRecentSessionOrder={actions.setRecentSessionOrder}
archivedSessionIds={archivedSessionIds} archivedSessionIds={archivedSessionIds}
startSession={startSession} startSession={startSession}
open={open} open={open}
@@ -15,7 +15,6 @@ export const zh = {
'groupBy.flat': '单列表', 'groupBy.flat': '单列表',
'orderBy.label': '排序方式', 'orderBy.label': '排序方式',
'orderBy.manual': '手动排序', 'orderBy.manual': '手动排序',
'orderBy.created': '创建时间',
'orderBy.updated': '最近更新', 'orderBy.updated': '最近更新',
'sessions.expand': '展开其余 {n} 个会话', 'sessions.expand': '展开其余 {n} 个会话',
'sessions.collapse': '收起', 'sessions.collapse': '收起',
@@ -84,7 +83,6 @@ export const en = {
'groupBy.flat': 'In one list', 'groupBy.flat': 'In one list',
'orderBy.label': 'Order by', 'orderBy.label': 'Order by',
'orderBy.manual': 'Manual', 'orderBy.manual': 'Manual',
'orderBy.created': 'Date created',
'orderBy.updated': 'Last updated', 'orderBy.updated': 'Last updated',
'sessions.expand': 'Show {n} more sessions', 'sessions.expand': 'Show {n} more sessions',
'sessions.collapse': 'Show less', 'sessions.collapse': 'Show less',
@@ -1,5 +1,5 @@
/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px /* Tree rows: project 34px, session 32px, radius 8, indent step 22px
single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps (16px slot + 6px gap). Hover swaps
are pure CSS: project folder -> chevron + action buttons; session time -> are pure CSS: project folder -> chevron + action buttons; session time ->
ellipsis button. */ ellipsis button. */
@@ -29,11 +29,11 @@
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
width: 100%; width: 100%;
min-height: 62px; min-height: 48px;
box-sizing: border-box; box-sizing: border-box;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 7px 8px; padding: 4px 8px;
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
@@ -64,9 +64,16 @@
line-height: 20px; line-height: 20px;
} }
.searchResultMeta {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
margin-left: 20px;
}
.searchResultWorkspace, .searchResultWorkspace,
.searchResultSnippet { .searchResultSnippet {
margin-left: 20px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -75,16 +82,20 @@
} }
.searchResultWorkspace { .searchResultWorkspace {
flex: none;
max-width: 40%;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
} }
.searchResultSnippet { .searchResultSnippet {
flex: 1;
min-width: 0;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
} }
/* Compact one-line Workspace row after removing the session-count subtitle. */ /* Compact one-line Workspace row after removing the session-count subtitle. */
.projectRow { .projectRow {
height: 36px; height: 34px;
align-items: center; align-items: center;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -95,7 +106,7 @@
/* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */ /* Session cell (figma): pad 8, a 16px status slot, then a 4px title gap. */
.sessionRow { .sessionRow {
height: 34px; height: 32px;
gap: 0; gap: 0;
/* Mount fade: session rows appear by unfolding a group (or the tree /* Mount fade: session rows appear by unfolding a group (or the tree
mounting). Stable row keys keep already-visible rows from replaying it. */ mounting). Stable row keys keep already-visible rows from replaying it. */
@@ -317,10 +317,12 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
</span> </span>
<span className={css.searchResultTitle}>{result.title}</span> <span className={css.searchResultTitle}>{result.title}</span>
</span> </span>
<span className={css.searchResultWorkspace}>{result.workspace}</span> <span className={css.searchResultMeta}>
{result.snippet !== undefined && ( <span className={css.searchResultWorkspace}>{result.workspace}</span>
<span className={css.searchResultSnippet}>{result.snippet}</span> {result.snippet !== undefined && (
)} <span className={css.searchResultSnippet}>{result.snippet}</span>
)}
</span>
</button> </button>
) )
} }
@@ -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. */ /** Session-list grouping mode: workspace sections or one flat recency list. */
export type WorkspaceGroupBy = 'workspace' | 'flat' export type WorkspaceGroupBy = 'workspace' | 'flat'
/** Session order: durable Workspace order or a derived timestamp order. */ /** Session order: durable Workspace order or an activity-promoted editable order. */
export type WorkspaceOrderBy = 'manual' | 'created' | 'updated' export type WorkspaceOrderBy = 'manual' | 'updated'
/** Workspace browser viewing state persisted across surface remounts and reloads. */ /** Workspace browser viewing state persisted across surface remounts and reloads. */
type WorkspaceViewState = { type WorkspaceViewState = {
@@ -18,6 +18,10 @@ type WorkspaceViewState = {
orderBy: WorkspaceOrderBy orderBy: WorkspaceOrderBy
/** Explicit zero-or-five-session state keyed by Workspace group identity. */ /** Explicit zero-or-five-session state keyed by Workspace group identity. */
workspaceExpansion: Record<string, boolean> workspaceExpansion: Record<string, boolean>
/** Editable per-Workspace order used by recent-update mode. */
recentSessionOrder: Record<string, string[]>
/** Last observed update timestamps used to detect promotion events. */
recentSessionUpdatedAt: Record<string, Record<string, number>>
} }
/** /**
@@ -28,6 +32,13 @@ type WorkspaceViewActions = {
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
syncRecentSessions: (
draft: WorkspaceViewState,
workspaceKey: string,
order: string[],
updatedAt: Record<string, number>,
) => void
setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void
} }
/** /**
@@ -36,12 +47,25 @@ type WorkspaceViewActions = {
*/ */
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> { export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
return defineStore({ return defineStore({
init: (): WorkspaceViewState => ({ groupBy: 'workspace', orderBy: 'manual', workspaceExpansion: {} }), init: (): WorkspaceViewState => ({
persist: 'dsh.workspace.view.v3', groupBy: 'workspace',
orderBy: 'manual',
workspaceExpansion: {},
recentSessionOrder: {},
recentSessionUpdatedAt: {},
}),
persist: 'dsh.workspace.view.v4',
actions: { actions: {
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode }, setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded }, setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded },
syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record<string, number>) => {
d.recentSessionOrder[workspaceKey] = order
d.recentSessionUpdatedAt[workspaceKey] = updatedAt
},
setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => {
d.recentSessionOrder[workspaceKey] = order
},
}, },
}) })
} }
@@ -34,7 +34,7 @@ export interface SessionNode {
} }
/** Session order selected by the Workspace browser. */ /** 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. */ /** One workspace group section: header row facts + visible top-level session rows. */
export interface GroupNode { export interface GroupNode {
@@ -108,14 +108,8 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1 return a.id < b.id ? -1 : 1
} }
/** Newest-created first, id as the deterministic tiebreak. */ function sortSessions(sessions: SessionSummary[]): void {
function byCreation(a: SessionSummary, b: SessionSummary): number { sessions.sort(byRecency)
if (b.createdAt !== a.createdAt) return b.createdAt - a.createdAt
return a.id < b.id ? -1 : 1
}
function sortSessions(sessions: SessionSummary[], orderBy: Exclude<SessionOrderBy, 'manual'>): void {
sessions.sort(orderBy === 'created' ? byCreation : byRecency)
} }
/** /**
@@ -150,7 +144,7 @@ function buildGroup(
orderBy: SessionOrderBy, orderBy: SessionOrderBy,
): Group { ): Group {
const sessions = [...members] const sessions = [...members]
if (orderBy !== 'manual') sortSessions(sessions, orderBy) if (orderBy !== 'manual') sortSessions(sessions)
return { key, workspaceId, cwd, createdAt, label, sessions } return { key, workspaceId, cwd, createdAt, label, sessions }
} }