Merge remote-tracking branch 'origin/master' into dshw/pr-2423

# Conflicts:
#	packages/client/ui-sidebar/src/client/SidebarRoot.tsx
This commit is contained in:
_Kerman
2026-08-13 04:32:57 +08:00
3517 changed files with 51843 additions and 30957 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-workspace",
"description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots",
"version": "0.0.1-rc.2",
"version": "0.0.1-rc.3",
"publishConfig": {
"access": "restricted"
},
@@ -195,7 +195,7 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
/** In-flight root-row drag: source identity plus the current insert marker. */
interface DragState {
/** Workspace id, or {@link UNGROUPED_KEY} for the browser-local loose-session account. */
workspaceKey: string
accountKey: string
sessionId: SessionNode['id']
/** Row the marker sits on and which half (insert above/below it). */
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
@@ -220,17 +220,17 @@ type SessionTreeProps = Pick<
> & {
workspaces: readonly WorkspaceView[]
/** Explicit persisted zero-or-five-session state by Workspace group. */
workspaceExpansion: Readonly<Record<string, boolean>>
groupExpansion: Readonly<Record<string, boolean>>
/** Persist one Workspace group's zero-or-five-session state. */
setWorkspaceExpanded: (key: string, expanded: boolean) => void
setGroupExpanded: (key: string, expanded: boolean) => void
/** Shared editable orders used by Workspace groups and the flat-list account. */
recentSessionOrder: Readonly<Record<string, readonly string[]>>
sessionOrderByAccount: Readonly<Record<string, readonly string[]>>
/** Last update timestamps observed for one-time recent-update promotions. */
recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
sessionUpdatedAtByAccount: 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
syncSessionOrderAccount: (accountKey: string, order: string[], updatedAt: Record<string, number>) => void
/** Apply a drag to one shared order. */
setRecentSessionOrder: (workspaceKey: string, order: string[]) => void
setSessionOrder: (accountKey: 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. */
@@ -250,8 +250,8 @@ function SessionTree({
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
insertWorkspaceBefore, insertSessionBefore, orderBy,
workspaceExpansion, setWorkspaceExpanded,
recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
groupExpansion, setGroupExpanded,
sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
@@ -269,12 +269,12 @@ function SessionTree({
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
useEffect(() => {
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],
if (current === undefined || currentGroup === undefined || Object.hasOwn(groupExpansion, currentGroup)) return
setGroupExpanded(currentGroup, true)
}, [current, currentGroup, setGroupExpanded, groupExpansion])
const expandedGroups = useMemo(
() => Object.entries(groupExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
[groupExpansion],
)
const ungroupedSessionIds = useMemo(() => {
const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
@@ -292,8 +292,8 @@ function SessionTree({
{ key: UNGROUPED_KEY, sessionIds: ungroupedSessionIds },
]
for (const { key, sessionIds } of accounts) {
const previousOrder = recentSessionOrder[key]
const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
const previousOrder = sessionOrderByAccount[key]
const previousUpdatedAt = sessionUpdatedAtByAccount[key] ?? {}
const next = nextSessionOrderAccount({
sessionIds,
previousOrder,
@@ -303,36 +303,36 @@ function SessionTree({
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
})
if (next.changed) {
syncRecentSessions(key, next.order.map(id => id as string), next.updatedAt)
syncSessionOrderAccount(key, next.order.map(id => id as string), next.updatedAt)
}
}
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, ungroupedSessionIds, workspaces])
}, [list, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, ungroupedSessionIds, workspaces])
const orderedWorkspaces = useMemo(() => {
return workspaces.map((workspace) => {
const stored = recentSessionOrder[workspace.workspaceId as string]
const stored = sessionOrderByAccount[workspace.workspaceId as string]
const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored)
return { ...workspace, sessionIds }
})
}, [recentSessionOrder, workspaces])
}, [sessionOrderByAccount, workspaces])
const orderedUngroupedSessionIds = useMemo(
() => reconciledSessionOrder(ungroupedSessionIds, recentSessionOrder[UNGROUPED_KEY]),
[recentSessionOrder, ungroupedSessionIds],
() => reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]),
[sessionOrderByAccount, ungroupedSessionIds],
)
const groups = useMemo(
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
expandedProjects,
...(recentSessionOrder[UNGROUPED_KEY] === undefined
expandedGroups,
...(sessionOrderByAccount[UNGROUPED_KEY] === undefined
? {}
: { ungroupedOrder: recentSessionOrder[UNGROUPED_KEY] }),
: { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }),
}),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects, recentSessionOrder],
[list, orderedWorkspaces, archivedSessionIds, expandedGroups, sessionOrderByAccount],
)
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)
const group = groups.find(candidate => candidate.key === activeDrag.accountKey)
if (group === undefined) return
const targetIndex = group.sessions.findIndex(session => session.id === over.id)
if (targetIndex === -1) return
@@ -343,16 +343,16 @@ function SessionTree({
? 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
const accountSessionIds = activeDrag.accountKey === UNGROUPED_KEY
? orderedUngroupedSessionIds
: orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceKey)?.sessionIds
: orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.accountKey)?.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) => {
setSessionOrder(activeDrag.accountKey, nextOrder.map(id => id as string))
if (orderBy === 'updated' || activeDrag.accountKey === UNGROUPED_KEY) return
insertSessionBefore(activeDrag.accountKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
console.warn('session reorder rejected:', reason)
})
}
@@ -455,11 +455,11 @@ function SessionTree({
if (group.expanded) {
setExpandedSessionGroups(keys => keys.filter(key => key !== group.key))
}
setWorkspaceExpanded(group.key, !group.expanded)
setGroupExpanded(group.key, !group.expanded)
}}
onCreate={() => {
if (group.workspaceId !== undefined) {
setWorkspaceExpanded(group.key, true)
setGroupExpanded(group.key, true)
startSession(group.workspaceId)
}
}}
@@ -483,11 +483,11 @@ function SessionTree({
).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 sameGroupDrag = drag !== null && drag.accountKey === group.key
const dragProps = {
start: () => {
sessionDropCommitted.current = false
setDrag({ workspaceKey: group.key, sessionId: node.id, over: null })
setDrag({ accountKey: group.key, sessionId: node.id, over: null })
},
active: sameGroupDrag,
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
@@ -545,7 +545,7 @@ function SessionTree({
/** 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,
orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
}: Pick<
SessionTreeProps,
| 'useSessions'
@@ -555,10 +555,10 @@ function FlatList({
| 'onSessionArchive'
| 'archivedSessionIds'
| 'orderBy'
| 'recentSessionOrder'
| 'recentSessionUpdatedAt'
| 'syncRecentSessions'
| 'setRecentSessionOrder'
| 'sessionOrderByAccount'
| 'sessionUpdatedAtByAccount'
| 'syncSessionOrderAccount'
| 'setSessionOrder'
| 't'
>) {
const list = useSessions(s => s)
@@ -570,8 +570,8 @@ function FlatList({
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 previousOrder = sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]
const previousUpdatedAt = sessionUpdatedAtByAccount[FLAT_SESSION_ORDER_KEY] ?? {}
const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
previousOrderBy.current = orderBy
const next = nextSessionOrderAccount({
@@ -583,17 +583,17 @@ function FlatList({
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
})
if (next.changed) {
syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt)
syncSessionOrderAccount(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt)
}
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions])
}, [list, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, sessionIds, syncSessionOrderAccount])
const rows = useMemo(() => {
const byId = new Map(baseRows.map(row => [row.id, row]))
return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY])
return reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
.flatMap((id) => {
const row = byId.get(id)
return row === undefined ? [] : [row]
})
}, [baseRows, recentSessionOrder, sessionIds])
}, [baseRows, sessionOrderByAccount, sessionIds])
const [drag, setDrag] = useState<DragState | null>(null)
const dropCommitted = useRef(false)
useNativeDragAcceptance(drag !== null)
@@ -611,7 +611,7 @@ function FlatList({
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))
setSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string))
}
const now = Date.now()
return (
@@ -636,7 +636,7 @@ function FlatList({
drag={{
start: () => {
dropCommitted.current = false
setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null })
setDrag({ accountKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null })
},
active,
marker: active && drag.over?.id === node.id ? drag.over.half : null,
@@ -769,17 +769,17 @@ export function WorkspaceBrowser({
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)
const groupExpansion = useStore(s => s.groupExpansion)
const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
const sessionUpdatedAtByAccount = useStore(s => s.sessionUpdatedAtByAccount)
useEffect(() => {
if (workspacePhase !== 'ready') return
actions.retainWorkspaceKeys([
actions.retainAccountKeys([
UNGROUPED_KEY,
FLAT_SESSION_ORDER_KEY,
...workspaces.map(workspace => workspace.workspaceId as string),
])
}, [actions.retainWorkspaceKeys, workspacePhase, workspaces])
}, [actions.retainAccountKeys, 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('')
@@ -1126,10 +1126,10 @@ export function WorkspaceBrowser({
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds}
orderBy={orderBy}
recentSessionOrder={recentSessionOrder}
recentSessionUpdatedAt={recentSessionUpdatedAt}
syncRecentSessions={actions.syncRecentSessions}
setRecentSessionOrder={actions.setRecentSessionOrder}
sessionOrderByAccount={sessionOrderByAccount}
sessionUpdatedAtByAccount={sessionUpdatedAtByAccount}
syncSessionOrderAccount={actions.syncSessionOrderAccount}
setSessionOrder={actions.setSessionOrder}
t={t}
/>
)
@@ -1140,12 +1140,12 @@ export function WorkspaceBrowser({
onSessionArchive={onSessionArchive}
forkSession={forkSession}
workspaces={workspaces}
workspaceExpansion={workspaceExpansion}
setWorkspaceExpanded={actions.setWorkspaceExpanded}
recentSessionOrder={recentSessionOrder}
recentSessionUpdatedAt={recentSessionUpdatedAt}
syncRecentSessions={actions.syncRecentSessions}
setRecentSessionOrder={actions.setRecentSessionOrder}
groupExpansion={groupExpansion}
setGroupExpanded={actions.setGroupExpanded}
sessionOrderByAccount={sessionOrderByAccount}
sessionUpdatedAtByAccount={sessionUpdatedAtByAccount}
syncSessionOrderAccount={actions.syncSessionOrderAccount}
setSessionOrder={actions.setSessionOrder}
archivedSessionIds={archivedSessionIds}
startSession={startSession}
open={open}
@@ -11,20 +11,20 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run
export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'
/** Session-list grouping mode: workspace sections or one flat recency list. */
export type WorkspaceGroupBy = 'workspace' | 'flat'
export type SessionGroupBy = 'workspace' | 'flat'
/** Session order: user-arranged only, or user-arranged plus activity promotion. */
export type WorkspaceOrderBy = 'manual' | 'updated'
export type SessionOrderBy = 'manual' | 'updated'
/** Workspace browser viewing state persisted across surface remounts and reloads. */
type WorkspaceViewState = {
groupBy: WorkspaceGroupBy
orderBy: WorkspaceOrderBy
groupBy: SessionGroupBy
orderBy: SessionOrderBy
/** Explicit zero-or-five-session state keyed by Workspace group identity. */
workspaceExpansion: Record<string, boolean>
groupExpansion: Record<string, boolean>
/** Shared editable order per Workspace group plus the browser-local flat-list account. */
recentSessionOrder: Record<string, string[]>
sessionOrderByAccount: Record<string, string[]>
/** Last observed update timestamps per order account for one-time promotion events. */
recentSessionUpdatedAt: Record<string, Record<string, number>>
sessionUpdatedAtByAccount: Record<string, Record<string, number>>
}
/**
@@ -32,17 +32,17 @@ type WorkspaceViewState = {
* return type); drift fails assignability at the defineStore call.
*/
type WorkspaceViewActions = {
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
retainWorkspaceKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void
syncRecentSessions: (
setGroupBy: (draft: WorkspaceViewState, mode: SessionGroupBy) => void
setOrderBy: (draft: WorkspaceViewState, mode: SessionOrderBy) => void
setGroupExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
retainAccountKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void
syncSessionOrderAccount: (
draft: WorkspaceViewState,
workspaceKey: string,
accountKey: string,
order: string[],
updatedAt: Record<string, number>,
) => void
setRecentSessionOrder: (draft: WorkspaceViewState, workspaceKey: string, order: string[]) => void
setSessionOrder: (draft: WorkspaceViewState, accountKey: string, order: string[]) => void
}
/**
@@ -54,33 +54,33 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
init: (): WorkspaceViewState => ({
groupBy: 'workspace',
orderBy: 'manual',
workspaceExpansion: {},
recentSessionOrder: {},
recentSessionUpdatedAt: {},
groupExpansion: {},
sessionOrderByAccount: {},
sessionUpdatedAtByAccount: {},
}),
persist: 'dsh.workspace.view.v4',
persist: 'dsh.workspace.view.v5',
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 },
retainWorkspaceKeys: (d, workspaceKeys: readonly string[]) => {
setGroupBy: (d, mode: SessionGroupBy) => { d.groupBy = mode },
setOrderBy: (d, mode: SessionOrderBy) => { d.orderBy = mode },
setGroupExpanded: (d, key: string, expanded: boolean) => { d.groupExpansion[key] = expanded },
retainAccountKeys: (d, workspaceKeys: readonly string[]) => {
const retained = new Set(workspaceKeys)
d.workspaceExpansion = Object.fromEntries(
Object.entries(d.workspaceExpansion).filter(([key]) => retained.has(key)),
d.groupExpansion = Object.fromEntries(
Object.entries(d.groupExpansion).filter(([key]) => retained.has(key)),
)
d.recentSessionOrder = Object.fromEntries(
Object.entries(d.recentSessionOrder).filter(([key]) => retained.has(key)),
d.sessionOrderByAccount = Object.fromEntries(
Object.entries(d.sessionOrderByAccount).filter(([key]) => retained.has(key)),
)
d.recentSessionUpdatedAt = Object.fromEntries(
Object.entries(d.recentSessionUpdatedAt).filter(([key]) => retained.has(key)),
d.sessionUpdatedAtByAccount = Object.fromEntries(
Object.entries(d.sessionUpdatedAtByAccount).filter(([key]) => retained.has(key)),
)
},
syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record<string, number>) => {
d.recentSessionOrder[workspaceKey] = order
d.recentSessionUpdatedAt[workspaceKey] = updatedAt
syncSessionOrderAccount: (d, accountKey: string, order: string[], updatedAt: Record<string, number>) => {
d.sessionOrderByAccount[accountKey] = order
d.sessionUpdatedAtByAccount[accountKey] = updatedAt
},
setRecentSessionOrder: (d, workspaceKey: string, order: string[]) => {
d.recentSessionOrder[workspaceKey] = order
setSessionOrder: (d, accountKey: string, order: string[]) => {
d.sessionOrderByAccount[accountKey] = order
},
},
})
@@ -77,7 +77,7 @@ export interface SearchResultSet {
/** Viewing state consumed by the derivation. */
export interface TreeView {
expandedProjects: readonly string[]
expandedGroups: readonly string[]
/** Browser-local order for Sessions without a backing Workspace account. */
ungroupedOrder?: readonly string[]
}
@@ -97,7 +97,7 @@ interface Group {
* @param cwd - directory path, or undefined for the ungrouped bucket.
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
*/
export function projectLabel(cwd: string | undefined): string {
export function workspaceLabel(cwd: string | undefined): string {
if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
return base !== undefined && base !== '' ? base : cwd
@@ -248,7 +248,7 @@ export function deriveGroups(
view: TreeView,
): GroupNode[] {
const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
const expandedGroups = new Set(view.expandedGroups)
const descendants = indexSubagentDescendants(list.byId)
const currentGroup = list.current === undefined
? undefined
@@ -256,7 +256,7 @@ export function deriveGroups(
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) {
const expanded = expandedProjects.has(g.key)
const expanded = expandedGroups.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
@@ -338,7 +338,7 @@ export function deriveSearchResults(
}
}
const labelOf = (summary: SessionSummary): string =>
workspaceBySession.get(summary.id) ?? projectLabel(summary.cwd)
workspaceBySession.get(summary.id) ?? workspaceLabel(summary.cwd)
const contentBySession = new Map<SessionId, SessionSearchResultItem>()
for (const item of content.items) {
if (!contentBySession.has(item.sessionId)) contentBySession.set(item.sessionId, item)
@@ -1,7 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
@@ -14,7 +14,7 @@ usePinnedBrowserLanguages('zh-CN')
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
await ctx.plugin(SlotRegistry).await()
const create = vi.fn(async (input: { name: string } | { path: string }) => ({
workspaceId: 'ws-new' as never,
path: 'name' in input ? `/projects/${input.name}` : input.path,
@@ -36,10 +36,10 @@ async function bench() {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
const locale = new LocaleService(ctx)
const locale = new LocaleRuntime(ctx)
ctx.provide('locale', locale)
return {
ctx, slots: ctx.get('slots') as SlotsService, locale, create, startSession, rename,
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename,
insertSessionBefore, open, clear, search, renameSession, binding, fork,
}
}
@@ -47,7 +47,7 @@ async function bench() {
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
/** Declare any subset of the holes with a single root registration ('root' is a single slot). */
function declare(slots: SlotsService, ...names: HoleName[]): () => void {
function declare(slots: SlotRegistry, ...names: HoleName[]): () => void {
const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }]))
return slots.register({ name: 'root', children } as never, () => null)
}
@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant'
import InvariantService from '@deepseek-ai/dsh-invariants'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
describe('invariant companion', () => {
it('registers under the package name with an empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(InvariantRegistry, { enabled: true })
await expect(ctx.plugin(WorkspaceInvariant).await()).resolves.toBeDefined()
})
@@ -16,7 +16,7 @@ import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
// The service reads its initial locale from the browser; these specs assert
@@ -31,7 +31,7 @@ beforeEach(() => { localStorage.clear() })
/** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */
async function createRuntime(): Promise<SlotTestRuntime> {
const runtime = await SlotTestRuntime.create()
const locale = new LocaleService(runtime.ctx)
const locale = new LocaleRuntime(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
return runtime
@@ -3,7 +3,7 @@ import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveFlat, deriveGroups, deriveSearchResults, projectLabel, relativeTime,
deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime,
UNGROUPED_KEY, UNGROUPED_LABEL,
} from '../src/client/tree.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
@@ -18,14 +18,14 @@ const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({
expandedProjects,
const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({
expandedGroups,
...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
})
const noArchive: readonly SessionId[] = []
@@ -155,7 +155,7 @@ describe('deriveGroups', () => {
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
[],
noArchive,
{ expandedProjects: [UNGROUPED_KEY] },
{ expandedGroups: [UNGROUPED_KEY] },
)
expect(groups).toHaveLength(1)
@@ -398,42 +398,42 @@ describe('createWorkspaceViewStore', () => {
expect(store.getSnapshot().orderBy).toBe('manual')
store.actions.setGroupBy('flat')
store.actions.setOrderBy('updated')
store.actions.setWorkspaceExpanded('alpha', true)
store.actions.syncRecentSessions('alpha', ['two', 'one'], { one: 1, two: 2 })
store.actions.setRecentSessionOrder('alpha', ['one', 'two'])
store.actions.setGroupExpanded('alpha', true)
store.actions.syncSessionOrderAccount('alpha', ['two', 'one'], { one: 1, two: 2 })
store.actions.setSessionOrder('alpha', ['one', 'two'])
expect(store.getSnapshot().groupBy).toBe('flat')
expect(store.getSnapshot()).toMatchObject({
orderBy: 'updated',
workspaceExpansion: { alpha: true },
recentSessionOrder: { alpha: ['one', 'two'] },
recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } },
groupExpansion: { alpha: true },
sessionOrderByAccount: { alpha: ['one', 'two'] },
sessionUpdatedAtByAccount: { alpha: { one: 1, two: 2 } },
})
})
it('removes view state outside the retained Workspace key set', () => {
const store = createWorkspaceViewStore().create()
store.actions.setWorkspaceExpanded('', true)
store.actions.setWorkspaceExpanded('alpha', true)
store.actions.setWorkspaceExpanded('deleted', true)
store.actions.syncRecentSessions('alpha', ['alpha-session'], { 'alpha-session': 2 })
store.actions.syncRecentSessions('deleted', ['deleted-session'], { 'deleted-session': 1 })
store.actions.setGroupExpanded('', true)
store.actions.setGroupExpanded('alpha', true)
store.actions.setGroupExpanded('deleted', true)
store.actions.syncSessionOrderAccount('alpha', ['alpha-session'], { 'alpha-session': 2 })
store.actions.syncSessionOrderAccount('deleted', ['deleted-session'], { 'deleted-session': 1 })
store.actions.retainWorkspaceKeys(['', 'alpha'])
store.actions.retainAccountKeys(['', 'alpha'])
const snapshot = store.getSnapshot()
expect(snapshot.workspaceExpansion).toEqual({ '': true, alpha: true })
expect(snapshot.recentSessionOrder).toEqual({ alpha: ['alpha-session'] })
expect(snapshot.recentSessionUpdatedAt).toEqual({ alpha: { 'alpha-session': 2 } })
expect(snapshot.groupExpansion).toEqual({ '': true, alpha: true })
expect(snapshot.sessionOrderByAccount).toEqual({ alpha: ['alpha-session'] })
expect(snapshot.sessionUpdatedAtByAccount).toEqual({ alpha: { 'alpha-session': 2 } })
})
})
describe('projectLabel', () => {
describe('workspaceLabel', () => {
it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
expect(projectLabel('/projects/demo/')).toBe('demo')
expect(projectLabel('C:\\projects\\demo\\')).toBe('demo')
expect(projectLabel('/')).toBe('/')
expect(workspaceLabel(undefined)).toBe(UNGROUPED_LABEL)
expect(workspaceLabel('')).toBe(UNGROUPED_LABEL)
expect(workspaceLabel('/projects/demo/')).toBe('demo')
expect(workspaceLabel('C:\\projects\\demo\\')).toBe('demo')
expect(workspaceLabel('/')).toBe('/')
})
})
@@ -30,7 +30,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial<Sessi
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
subagentsByParent: {}, tasksBySession: {},
subagentsByParent: {}, jobsBySession: {},
currentAddress: undefined,
...overrides,
})
@@ -104,16 +104,16 @@ describe('WorkspaceBrowser', () => {
}
const b = mount({ useWorkspaces: hook(pending) })
act(() => {
b.store.actions.setWorkspaceExpanded('deleted', true)
b.store.actions.syncRecentSessions('deleted', ['session'], { session: 1 })
b.store.actions.setGroupExpanded('deleted', true)
b.store.actions.syncSessionOrderAccount('deleted', ['session'], { session: 1 })
})
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ deleted: true })
expect(b.store.getSnapshot().groupExpansion).toEqual({ deleted: true })
rerender(b, { useWorkspaces: hook(workspaceState([])) })
await waitFor(() => {
expect(b.store.getSnapshot().workspaceExpansion).toEqual({})
expect(b.store.getSnapshot().recentSessionOrder).toEqual({ [UNGROUPED_KEY]: [] })
expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({ [UNGROUPED_KEY]: {} })
expect(b.store.getSnapshot().groupExpansion).toEqual({})
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({ [UNGROUPED_KEY]: [] })
expect(b.store.getSnapshot().sessionUpdatedAtByAccount).toEqual({ [UNGROUPED_KEY]: {} })
})
})
@@ -173,7 +173,7 @@ describe('WorkspaceBrowser', () => {
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
.toEqual(['one', 'two', 'three'])
})
@@ -185,14 +185,14 @@ describe('WorkspaceBrowser', () => {
})
fireEvent.dragStart(one, { dataTransfer: dragData() })
fireDrag(three, 'drop', 180)
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
.toEqual(['two', 'three', 'one'])
expect(insertSessionBefore).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
.toEqual(['one', 'two', 'three'])
})
@@ -244,9 +244,9 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByRole('button', { name: '收起' })).toBeTruthy()
fireEvent.click(screen.getByText('alpha'))
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: false })
expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: false })
fireEvent.click(screen.getByText('alpha'))
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true })
expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true })
expect(screen.queryByText('session-6')).toBeNull()
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
})
@@ -272,7 +272,7 @@ describe('WorkspaceBrowser', () => {
})
fireEvent.dragStart(one, { dataTransfer: dragData() })
fireDrag(two, 'drop', 180)
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
@@ -283,16 +283,16 @@ describe('WorkspaceBrowser', () => {
const updated = sessionState([summary('one', 4), summary('two', 2)])
rerender(b, { useSessions: hook(updated) })
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionUpdatedAt.alpha).toEqual({ one: 4, two: 2 })
expect(b.store.getSnapshot().sessionUpdatedAtByAccount.alpha).toEqual({ one: 4, two: 2 })
})
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
// Entering Last updated performs one complete recency sort.
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two'])
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['one', 'two'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
})
@@ -301,7 +301,7 @@ describe('WorkspaceBrowser', () => {
const promoted = sessionState([summary('one', 4), summary('two', 5)])
rerender(b, { useSessions: hook(promoted) })
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
})
@@ -310,7 +310,7 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(promoted),
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
})
expect(restored.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
expect(restored.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
})
@@ -378,11 +378,11 @@ describe('WorkspaceBrowser', () => {
startSession,
})
startSession.mockImplementation(() => {
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true })
expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true })
})
expect(screen.queryByText('alpha-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ alpha: true })
expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true })
expect(screen.getByText('alpha-s')).toBeTruthy()
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
})
@@ -858,18 +858,18 @@ describe('WorkspaceBrowser', () => {
}
dragAfter('one', 'three')
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
dragAfter('two', 'one')
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
expect(insertSessionBefore).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['one', 'two', 'three'])
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['one', 'two', 'three'])
})
dragAfter('one', 'three')
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
expect(insertSessionBefore).not.toHaveBeenCalled()
b.view.unmount()
@@ -878,7 +878,7 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([])),
insertSessionBefore,
})
expect(restored.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
expect(restored.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
expect.stringContaining('two'),
expect.stringContaining('three'),
@@ -28,7 +28,7 @@ function hook<T>(snapshot: T) {
return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
}
const sessions: SessionListState = {
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
}
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
+1 -1
View File
@@ -30,7 +30,7 @@
"path": "../ui-conversation"
},
{
"path": "../../support/invariants"
"path": "../../runtime-diagnostics/invariants"
}
]
}