fix: docs
This commit is contained in:
imccyu
2026-07-30 15:56:41 +08:00
parent 085383b145
commit 1e10966ef6
160 changed files with 2521 additions and 1135 deletions

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar"
@@ -39,6 +40,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -47,7 +49,9 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",

View File

@@ -27,28 +27,27 @@ import css from './WorkspaceBrowser.module.css'
*/
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
{ type: 'label' as const, id: 'group-by', text: 'Group by' },
{ id: 'workspace', label: 'WorkSpace' },
{ id: 'flat', label: 'In one list' },
]
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter(k => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu({ groupBy, onPick }: {
function GroupByMenu({ groupBy, onPick, t }: {
groupBy: 'workspace' | 'flat'
onPick: (mode: 'workspace' | 'flat') => void
t: WorkspaceBrowserProps['t']
}) {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
items={[
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
{ id: 'workspace', label: t('groupBy.workspace') },
{ id: 'flat', label: t('groupBy.flat') },
]}
selectedId={groupBy}
onSelect={(id) => {
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
@@ -63,7 +62,7 @@ function GroupByMenu({ groupBy, onPick }: {
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
aria-label={t('groupBy.label')}
onClick={() => { setOpen(v => !v) }}
>
<IconPersonalizationOutline16 />
@@ -83,7 +82,7 @@ interface DragState {
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the browser root (the query outlives the tree). */
@@ -99,7 +98,7 @@ type SessionTreeProps = Pick<
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, forkSession, workspaces, query,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
@@ -122,9 +121,9 @@ function SessionTree({
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
{groups.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
)}
{groups.map(group => (
// Group section: header row + expanded top-level session rows. The
@@ -133,6 +132,7 @@ function SessionTree({
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
t={t}
onToggle={() => { setExpandedProjects(l => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
@@ -195,6 +195,7 @@ function SessionTree({
onRename={onSessionRename}
onFork={forkSession}
drag={dragProps}
t={t}
/>
)
})}
@@ -207,15 +208,15 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query'>) {
function FlatList({ useSessions, open, forkSession, onSessionRename, query, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'query' | 't'>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
<div className={css.list} role="tree" aria-label={t('section.sessions')}>
{rows.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
<div className={css.empty}>{query === '' ? t('empty.none') : t('empty.noMatches')}</div>
)}
{rows.map(node => (
<SessionNodeItem
@@ -226,6 +227,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, query }: Pi
onOpen={open}
onRename={onSessionRename}
onFork={forkSession}
t={t}
/>
))}
</div>
@@ -256,6 +258,7 @@ export function WorkspaceBrowser({
createWorkspace,
useDirectoryFlow,
renderSlot,
t,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -383,16 +386,16 @@ export function WorkspaceBrowser({
<div className={css.sectionHeader}>
{wide && (
<span className={clsx(css.sectionLabel, css.wide)}>
{groupBy === 'flat' ? 'Sessions' : 'Workspaces'}
{groupBy === 'flat' ? t('section.sessions') : t('section.workspaces')}
</span>
)}
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />}
<Tooltip label="New Workspace" disabled={wide}>
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} t={t} />}
<Tooltip label={t('workspace.new')} disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label="Create workspace"
aria-label={t('create.confirm')}
onClick={() => {
setWsPickerOpen(v => !v)
}}
@@ -402,6 +405,7 @@ export function WorkspaceBrowser({
</Tooltip>
{/* Picker menu + create dialogs (same package — direct composition). */}
<WorkspaceCreateFlow
t={t}
open={wsPickerOpen}
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
@@ -421,11 +425,11 @@ export function WorkspaceBrowser({
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Rail: the icon is the region's search control. */}
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
<Tooltip label="Search" disabled={wide}>
<Tooltip label={t('search')} disabled={wide}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
aria-label={t('search.sessions.aria')}
tabIndex={wide ? -1 : 0}
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
>
@@ -437,7 +441,7 @@ export function WorkspaceBrowser({
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
placeholder={t('search.placeholder')}
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
@@ -446,7 +450,7 @@ export function WorkspaceBrowser({
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
aria-label={t('search.clear')}
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
@@ -458,7 +462,12 @@ export function WorkspaceBrowser({
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} query={query} />
? (
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} query={query} t={t}
/>
)
: (
<SessionTree
useSessions={useSessions}
@@ -469,6 +478,7 @@ export function WorkspaceBrowser({
open={open}
query={query}
insertSessionBefore={insertSessionBefore}
t={t}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
@@ -485,18 +495,19 @@ export function WorkspaceBrowser({
<Modal
open={renameTarget !== null}
onClose={closeRename}
title="Rename workspace"
closeLabel={t('close')}
title={t('rename.workspace.title')}
footer={(
<>
<Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button>
<Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button>
<Button variant="outline" disabled={renaming} onClick={closeRename}>{t('cancel')}</Button>
<Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>{t('rename')}</Button>
</>
)}
>
<input
className={css.renameInput}
value={renameDraft}
aria-label="Workspace name"
aria-label={t('field.workspaceName')}
autoFocus
disabled={renaming}
onFocus={(e) => { e.target.select() }}
@@ -511,7 +522,7 @@ export function WorkspaceBrowser({
}}
/>
{renameDuplicate && (
<div className={css.renameError} role="alert">A workspace named {renameTrimmed} already exists.</div>
<div className={css.renameError} role="alert">{t('conflict.named', { name: renameTrimmed })}</div>
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
@@ -519,18 +530,19 @@ export function WorkspaceBrowser({
<Modal
open={sessionRenameTarget !== null}
onClose={closeSessionRename}
title="Rename session"
closeLabel={t('close')}
title={t('rename.session.title')}
footer={(
<>
<Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>Cancel</Button>
<Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>Rename</Button>
<Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>{t('cancel')}</Button>
<Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>{t('rename')}</Button>
</>
)}
>
<input
className={css.renameInput}
value={sessionRenameDraft}
aria-label="Session name"
aria-label={t('field.sessionName')}
autoFocus
disabled={sessionRenaming}
onFocus={(e) => { e.target.select() }}
@@ -549,25 +561,26 @@ export function WorkspaceBrowser({
<Modal
open={deleteTarget !== null}
onClose={closeDelete}
title="Delete workspace"
closeLabel={t('close')}
title={t('delete.workspace')}
{...deleteTarget === null
? {}
: { description: `This removes “${deleteTarget.title}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.` }}
: { description: t('delete.desc', { name: deleteTarget.title }) }}
footer={(
<>
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button variant="outline" disabled={deleting} onClick={closeDelete}>{t('cancel')}</Button>
<Button
variant="outline"
className={css.deleteAction}
disabled={deleting}
onClick={confirmDelete}
>
Delete workspace
{t('delete.workspace')}
</Button>
</>
)}
>
{deleting && <div className={css.deleteStatus} role="status">Deleting workspace</div>}
{deleting && <div className={css.deleteStatus} role="status">{t('delete.pending')}</div>}
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
</Modal>
</div>

View File

@@ -26,6 +26,8 @@ type ModalKind = 'create' | 'folder-error' | null
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
/** The standard locale seat, forwarded by whichever slot entry hosts the flow. */
t: WorkspacePickerProps['t']
/** Popover visibility (anchor button toggle state, owner-local). */
open: boolean
/** The anchor button element — the popover's placement anchor. */
@@ -56,6 +58,7 @@ export interface WorkspaceCreateFlowProps {
* @returns menu + dialog elements.
*/
export function WorkspaceCreateFlow({
t,
open,
anchorRef,
useWorkspaces,
@@ -106,9 +109,9 @@ export function WorkspaceCreateFlow({
}, [flowOpen, flowAvailable])
const createEntries: MenuEntry[] = [
...(flowAvailable
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder', icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
? [{ id: OPEN_LOCAL_FOLDER, label: t('menu.openFolder'), icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
{ id: CREATE_NEW, label: t('menu.createWorkspace'), icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
@@ -218,42 +221,44 @@ export function WorkspaceCreateFlow({
portal
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">{t('picker.loading')}</div>}
{renderDirectoryFlow(flowOwner)}
<Modal
open={modalKind === 'folder-error'}
onClose={closeModal}
title={folderConflict ? 'A workspace with this name already exists' : 'Couldnt open folder'}
closeLabel={t('close')}
title={folderConflict ? t('conflict.title') : t('folderError.title')}
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>{t('cancel')}</Button>
{/* Retrying needs an occupant to serve the flow; without one the
* button would open a flow nobody can answer or cancel. */}
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>Choose again</Button>
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>{t('folderError.retry')}</Button>
</>
)}
>
<div className={css.modalError} role="alert">
{folderConflict
? 'Choose a folder with a different name.'
? t('conflict.hint')
: modalError}
</div>
</Modal>
<Modal
open={modalKind === 'create'}
onClose={closeModal}
title="Create a new workspace"
description="The name is used for both the workspace and its new folder."
closeLabel={t('close')}
title={t('menu.createWorkspace')}
description={t('create.desc')}
footer={(
<>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>Cancel</Button>
<Button variant="outline" className={css.modalAction} disabled={creating} onClick={closeModal}>{t('cancel')}</Button>
<Button
variant="primary"
className={css.modalAction}
disabled={creating || normalizedWorkspaceName === '' || duplicateWorkspaceName}
onClick={confirmCreate}
>
Create workspace
{t('create.confirm')}
</Button>
</>
)}
@@ -261,8 +266,8 @@ export function WorkspaceCreateFlow({
<input
className={css.modalInput}
value={workspaceName}
placeholder="Workspace name"
aria-label="New workspace name"
placeholder={t('field.workspaceName')}
aria-label={t('create.name.aria')}
autoFocus
disabled={creating}
onChange={(event) => { setWorkspaceName(event.target.value); setModalError(null) }}
@@ -275,9 +280,9 @@ export function WorkspaceCreateFlow({
}
}}
/>
{creating && <div className={css.modalStatus} role="status">Creating workspace</div>}
{creating && <div className={css.modalStatus} role="status">{t('create.pending')}</div>}
{duplicateWorkspaceName && (
<div className={css.modalError} role="alert">A workspace named {normalizedWorkspaceName} already exists.</div>
<div className={css.modalError} role="alert">{t('conflict.named', { name: normalizedWorkspaceName })}</div>
)}
{modalError !== null && <div className={css.modalError} role="alert">{modalError}</div>}
</Modal>
@@ -301,9 +306,11 @@ export function WorkspacePicker({
createWorkspace,
useDirectoryFlow,
renderSlot,
t,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
t={t}
open={open}
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}

View File

@@ -19,7 +19,7 @@
* and a hole has exactly one declaring entry — they carry the same owner
* contract and the same occupant.
*/
import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -111,13 +111,14 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
/** Full browser props: shell owner share + viewing store + injected actions + the locale seat. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
& Omit<WorkspaceBrowserInjected, 'hooks'>
& DirectoryPickingHooks
& PropsLocale<'workspace'>
/**
* Picker-private injected share. Pick semantics remain in the owner's onPick
@@ -130,12 +131,13 @@ export type WorkspacePickerInjected = DirectoryPickingInjected & {
}
/**
* Full picker props: the owner share plus the creation callback. The two
* picker holes (blank-session hero / New-Session view) share one owner
* currency, so one composed type serves both registrations.
* Full picker props: the owner share plus the creation callback and the
* locale seat. The two picker holes (blank-session hero / New-Session view)
* share one owner currency, so one composed type serves both registrations.
*/
export type WorkspacePickerProps =
PropsRuntime<'conversation.hero.workspace'>
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
& Omit<WorkspacePickerInjected, 'hooks'>
& DirectoryPickingHooks
& PropsLocale<'workspace'>

View File

@@ -11,15 +11,29 @@
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
import { en, zh, type WorkspaceKey } from './locales.ts'
export type {
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
export type { WorkspaceKey } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** The workspace browsing region and pick/create flow copy. */
workspace: WorkspaceKey
}
}
/** Dictionary namespace owned by this plugin. */
const NS = 'workspace'
/**
* Required services (cordis fiber inject). The target slots are declared by
@@ -29,7 +43,7 @@ export type {
* provides a waitable service. apply therefore registers via
* declaration-aware deferral instead of assuming order.
*/
export const inject = ['slots', 'sessions', 'workspaces']
export const inject = ['slots', 'sessions', 'workspaces', 'locale']
/**
* Register the browser and picker once their slot declarations are on the
@@ -38,6 +52,8 @@ export const inject = ['slots', 'sessions', 'workspaces']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
// Stable per-surface occupancy sources (the renderer's hook cache keys by
// source identity): true while the surface's directory-flow hole is filled.
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
@@ -93,6 +109,7 @@ export function apply(ctx: ClientContext): void {
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
locale: NS,
},
WorkspaceBrowser,
)),
@@ -102,6 +119,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.hero.workspace',
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
inject: pickerInjected,
locale: NS,
},
WorkspacePicker,
)),

View File

@@ -0,0 +1,118 @@
/**
* `workspace` namespace dictionaries: the browsing region (section header,
* search, tree rows, dialogs) and the pick/create flow. Runtime failure
* messages (wire error strings) pass through untranslated by policy.
*/
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'group.ungrouped': '未分组',
'session.new': '新会话',
'section.workspaces': '工作区',
'section.sessions': '会话',
'groupBy.label': '分组方式',
'groupBy.workspace': '按工作区',
'groupBy.flat': '单列表',
'empty.none': '暂无会话',
'empty.noMatches': '无匹配结果',
'workspace.new': '新建工作区',
'search.sessions.aria': '搜索会话',
'search.placeholder': '搜索名称、关键词…',
'search.clear': '清除搜索',
'menu.openFolder': '打开本地文件夹…',
'menu.createWorkspace': '新建工作区',
'picker.loading': '正在加载工作区…',
'conflict.title': '已存在同名工作区',
'conflict.hint': '请选择其他名称的文件夹。',
'conflict.named': '已存在名为“{name}”的工作区。',
'folderError.title': '无法打开文件夹',
'folderError.retry': '重新选择',
'create.confirm': '创建工作区',
'create.desc': '该名称将同时用于工作区及其新文件夹。',
'create.name.aria': '新工作区名称',
'create.pending': '正在创建工作区…',
'rename': '重命名',
'rename.workspace.title': '重命名工作区',
'rename.session.title': '重命名会话',
'field.workspaceName': '工作区名称',
'field.sessionName': '会话名称',
'delete.workspace': '删除工作区',
'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。',
'delete.pending': '正在删除工作区…',
'menu.fork': '分叉会话',
'menu.deleteSession': '删除会话',
'sessions.count.one': '{n} 个会话',
'sessions.count.other': '{n} 个会话',
'actions.workspace.aria': '工作区“{name}”的操作',
'actions.session.aria': '会话“{name}”的操作',
'actions.newSession.aria': '在“{name}”中新建会话',
'status.running': '进行中',
'status.idle': '空闲',
'hover.created': '创建于 {time}',
'date.ymd': '{y}年{m}月{d}日',
'time.now': '刚刚',
'time.minutes': '{n}分钟',
'time.hours': '{n}小时',
'time.days': '{n}天',
'time.months': '{n}个月',
'time.years': '{n}年',
'time.ago': '{t}前',
} satisfies Record<string, string>
/** The workspace namespace key union. */
export type WorkspaceKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'group.ungrouped': 'Ungrouped',
'session.new': 'New Session',
'section.workspaces': 'Workspaces',
'section.sessions': 'Sessions',
'groupBy.label': 'Group by',
'groupBy.workspace': 'WorkSpace',
'groupBy.flat': 'In one list',
'empty.none': 'No sessions yet',
'empty.noMatches': 'No matches',
'workspace.new': 'New Workspace',
'search.sessions.aria': 'Search sessions',
'search.placeholder': 'Search name, keywords...',
'search.clear': 'Clear search',
'menu.openFolder': 'Open local folder…',
'menu.createWorkspace': 'Create a new workspace',
'picker.loading': 'Loading workspaces…',
'conflict.title': 'A workspace with this name already exists',
'conflict.hint': 'Choose a folder with a different name.',
'conflict.named': 'A workspace named “{name}” already exists.',
'folderError.title': 'Couldnt open folder',
'folderError.retry': 'Choose again',
'create.confirm': 'Create workspace',
'create.desc': 'The name is used for both the workspace and its new folder.',
'create.name.aria': 'New workspace name',
'create.pending': 'Creating workspace…',
'rename': 'Rename',
'rename.workspace.title': 'Rename workspace',
'rename.session.title': 'Rename session',
'field.workspaceName': 'Workspace name',
'field.sessionName': 'Session name',
'delete.workspace': 'Delete workspace',
'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.',
'delete.pending': 'Deleting workspace…',
'menu.fork': 'Fork session',
'menu.deleteSession': 'Delete session',
'sessions.count.one': '{n} session',
'sessions.count.other': '{n} sessions',
'actions.workspace.aria': 'Workspace actions for {name}',
'actions.session.aria': 'Session actions for {name}',
'actions.newSession.aria': 'New session in {name}',
'status.running': 'Running',
'status.idle': 'Idle',
'hover.created': 'Created {time}',
'date.ymd': '{y}-{m}-{d}',
'time.now': 'now',
'time.minutes': '{n}min',
'time.hours': '{n}h',
'time.days': '{n}d',
'time.months': '{n}mo',
'time.years': '{n}y',
'time.ago': '{t} ago',
} satisfies Record<WorkspaceKey, string>

View File

@@ -12,32 +12,55 @@ import {
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { GroupNode, SessionNode } from '../tree.ts'
import { formatRelativeTime } from '../tree.ts'
import { relativeTime } from '../tree.ts'
import css from './Rows.module.css'
const SESSION_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
{ id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true },
]
/** The standard locale seat, prop-passed from the browser root. */
type RowTranslate = WorkspaceBrowserProps['t']
const WORKSPACE_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
]
/** Row display title: blank rows show the localized New Session label. */
function displayTitle(node: SessionNode, t: RowTranslate): string {
return node.blank ? t('session.new') : node.title
}
/** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */
function timeLabel(updatedAt: number, now: number, t: RowTranslate): string {
const { unit, n } = relativeTime(updatedAt, now)
return unit === 'now' ? t('time.now') : t(`time.${unit}`, { n })
}
/** Hover-card variant: distances wrap in the ago template; the now bucket stays bare (no "now ago"). */
function hoverTimeLabel(updatedAt: number, now: number, t: RowTranslate): string {
const { unit, n } = relativeTime(updatedAt, now)
return unit === 'now' ? t('time.now') : t('time.ago', { t: t(`time.${unit}`, { n }) })
}
/**
* Absolute creation time through the dictionary's date template (the message
* clock pattern): `toLocaleString` would follow the browser language, not the
* app locale, and produce mixed-language text after a switch.
*/
function createdLabel(createdAt: number, t: RowTranslate): string {
const d = new Date(createdAt)
const pad2 = (v: number): string => String(v).padStart(2, '0')
const date = t('date.ymd', { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() })
return t('hover.created', { time: `${date} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` })
}
/** Hover-card body: workspace title, full directory path, absolute creation time. */
function WorkspaceHoverContent({ label, cwd, createdAt }: {
function WorkspaceHoverContent({ label, cwd, createdAt, t }: {
label: string
cwd: string | undefined
createdAt: number
t: RowTranslate
}) {
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{label}</div>
<div className={css.hoverPath}>{cwd}</div>
<div className={css.hoverTime}>{`Created ${new Date(createdAt).toLocaleString()}`}</div>
<div className={css.hoverTime}>{createdLabel(createdAt, t)}</div>
</div>
)
}
@@ -50,19 +73,27 @@ function WorkspaceHoverContent({ label, cwd, createdAt }: {
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @param props.t - the browser root's locale seat.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
actions?: { rename: () => void; delete: () => void } | undefined
t: RowTranslate
}) {
const row = group
// The ungrouped bucket has no workspace title: its label is dictionary copy.
const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
const count = t(row.sessionCount === 1 ? 'sessions.count.one' : 'sessions.count.other', { n: row.sessionCount })
const [menuOpen, setMenuOpen] = useState(false)
const workspaceMenuItems = [
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
{ id: 'delete', label: t('delete.workspace'), icon: <IconTrashOutline16 />, danger: true },
]
const ownRow = (
<div
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
@@ -77,7 +108,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</span>
<span className={css.projectText}>
<span className={css.title}>{row.label}</span>
<span className={css.title}>{label}</span>
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
@@ -85,12 +116,12 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={WORKSPACE_MENU_ITEMS}
items={workspaceMenuItems}
onSelect={(id) => {
setMenuOpen(false)
// Unknown ids leave before the dispatch: a future menu row must
// not inherit the destructive branch as an else fallback.
/* v8 ignore next -- WORKSPACE_MENU_ITEMS carries exactly these two rows today. */
/* v8 ignore next -- workspaceMenuItems carries exactly these two rows today. */
if (id !== 'rename' && id !== 'delete') return
if (id === 'rename') actions.rename()
else actions.delete()
@@ -101,7 +132,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<button
type="button"
className={css.iconButton}
aria-label={`Workspace actions for ${row.label}`}
aria-label={t('actions.workspace.aria', { name: label })}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
@@ -112,7 +143,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
<button
type="button"
className={css.iconButton}
aria-label={`New session in ${row.label}`}
aria-label={t('actions.newSession.aria', { name: label })}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconPlusOutline16 />
@@ -125,7 +156,7 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
return (
<HoverCard
anchor={ownRow}
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} />}
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} t={t} />}
disabled={menuOpen}
/>
)
@@ -140,14 +171,14 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
* @returns the session row.
*/
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
function SessionHoverContent({ node, now, t }: { node: SessionNode; now: number; t: RowTranslate }) {
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{node.title}</div>
<div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
<div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>
<div className={css.hoverStatus}>
<StateDot state={node.running ? 'ongoing' : 'done'} />
<span>{node.running ? 'Running' : 'Idle'}</span>
<span>{node.running ? t('status.running') : t('status.idle')}</span>
</div>
</div>
)
@@ -177,7 +208,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag }: {
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: {
node: SessionNode
currentId: string | undefined
now: number
@@ -188,10 +219,17 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
onFork: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group sessions outside search). */
drag?: RowDragProps | undefined
t: RowTranslate
}) {
const row = node
const title = displayTitle(node, t)
const selected = node.id === currentId
const [menuOpen, setMenuOpen] = useState(false)
const sessionMenuItems = [
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
{ id: 'fork', label: t('menu.fork'), icon: <IconBranchOutline16 /> },
{ id: 'delete', label: t('menu.deleteSession'), icon: <IconTrashOutline16 />, danger: true },
]
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
const ownRow = (
<div
@@ -227,13 +265,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
}}
>
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.title}>{title}</span>
<span className={css.time}>{timeLabel(row.updatedAt, now, t)}</span>
<span className={css.rowActions}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={SESSION_MENU_ITEMS}
items={sessionMenuItems}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename(node.id, row.title)
@@ -245,7 +283,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
<button
type="button"
className={css.iconButton}
aria-label={`Session actions for ${row.title}`}
aria-label={t('actions.session.aria', { name: title })}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
@@ -258,7 +296,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
return (
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
content={<SessionHoverContent node={node} now={now} t={t} />}
disabled={menuOpen || drag?.active === true}
/>
)

View File

@@ -14,7 +14,10 @@ export const UNGROUPED_LABEL = 'Ungrouped'
/** One top-level session row in a group or the flat list. */
export interface SessionNode {
id: SessionId
/** Stored display title; the renderer substitutes the localized New Session label for blank rows. */
title: string
/** The provisional blank session (renderer shows the localized New Session title). */
blank: boolean
running: boolean
updatedAt: number
}
@@ -76,7 +79,11 @@ function sessionVisible(session: SessionSummary, current: SessionId | undefined)
return !session.blank || session.id === current
}
/** A blank session is the selected Workspace's provisional New Session row. */
/**
* A blank session is the selected Workspace's provisional New Session row;
* its canonical title never enters search (blank rows are query-excluded)
* and the renderer localizes its display label.
*/
function sessionTitle(session: SessionSummary): string {
return session.blank ? 'New Session' : session.displayTitle
}
@@ -134,6 +141,7 @@ function sessionNode(s: SessionSummary): SessionNode {
return {
id: s.id,
title: sessionTitle(s),
blank: s.blank,
running: s.running,
updatedAt: s.updatedAt,
}
@@ -147,7 +155,8 @@ function sessionNode(s: SessionSummary): SessionNode {
* case-insensitive display-title substring): expansion state is ignored —
* matching sessions are forced visible, groups without a display-title or
* label hit are dropped, and a label-only hit
* keeps the bare group header. Non-current blank sessions are excluded.
* keeps the bare group header. Non-current blank sessions are excluded
* everywhere; blank placeholders never match a search query.
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param view - local expansion arrays and search query.
@@ -180,7 +189,7 @@ export function deriveGroups(
sessions: expanded ? g.sessions.map(sessionNode) : [],
})
} else {
const matches = g.sessions.filter(session => sessionTitle(session).toLowerCase().includes(q))
const matches = g.sessions.filter(session => !session.blank && sessionTitle(session).toLowerCase().includes(q))
if (matches.length === 0 && !g.label.toLowerCase().includes(q)) continue
groups.push({
key: g.key,
@@ -213,28 +222,38 @@ export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined || !sessionVisible(s, list.current)) continue
if (q !== '' && !sessionTitle(s).toLowerCase().includes(q)) continue
if (q !== '' && (s.blank || !sessionTitle(s).toLowerCase().includes(q))) continue
rows.push(s)
}
rows.sort(byRecency)
return rows.map(sessionNode)
}
/** Relative-time bucket of a session row's trailing label. */
export type RelativeTimeUnit = 'now' | 'minutes' | 'hours' | 'days' | 'months' | 'years'
/** Structured relative time: the bucket plus its magnitude (0 for 'now'). */
export interface RelativeTime {
unit: RelativeTimeUnit
n: number
}
/**
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
* Compact relative time for session rows, as a structured bucket the
* renderer localizes ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en).
* @param updatedAt - epoch ms of the session's last activity.
* @param now - current epoch ms (injected for pure rendering).
* @returns the row's trailing time label.
* @returns the row's trailing time bucket and magnitude.
*/
export function formatRelativeTime(updatedAt: number, now: number): string {
export function relativeTime(updatedAt: number, now: number): RelativeTime {
const MIN = 60_000
const HOUR = 3_600_000
const DAY = 86_400_000
const diff = Math.max(0, now - updatedAt)
if (diff < MIN) return 'now'
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
return `${Math.floor(diff / (365 * DAY))}y`
if (diff < MIN) return { unit: 'now', n: 0 }
if (diff < HOUR) return { unit: 'minutes', n: Math.floor(diff / MIN) }
if (diff < DAY) return { unit: 'hours', n: Math.floor(diff / HOUR) }
if (diff < 30 * DAY) return { unit: 'days', n: Math.floor(diff / DAY) }
if (diff < 365 * DAY) return { unit: 'months', n: Math.floor(diff / (30 * DAY)) }
return { unit: 'years', n: Math.floor(diff / (365 * DAY)) }
}

View File

@@ -15,10 +15,10 @@ export const name = 'client-ui-workspace-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin registering one presentational
* component into two host-declared slots its inject face is two stateless
* RPC wrappers plus a create-and-open call; it emits no cordis events and
* owns no cross-plugin mutable state.
* No runtime invariant: a pure-consumer plugin registering presentational
* components into two host-declared slots plus its locale dictionaries — its
* inject face is stateless RPC wrappers plus a create-and-open call; it
* emits no cordis events and owns no cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}

View File

@@ -1,6 +1,7 @@
import { Context } from '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 { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
@@ -26,8 +27,10 @@ async function bench() {
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear, binding, fork } as never)
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
return {
ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename,
ctx, slots: ctx.get('slots') as SlotsService, locale, create, startSession, rename,
insertSessionBefore, open, clear, renameSession, binding, fork,
}
}
@@ -42,7 +45,7 @@ function declare(slots: SlotsService, ...names: HoleName[]): () => void {
describe('ui-workspace apply', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'locale'])
})
it('registers browser and pickers for declarations arriving before or after apply', async () => {
@@ -50,6 +53,10 @@ describe('ui-workspace apply', () => {
declare(before.slots, 'sidebar.workspaces')
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser)
// Copy rides the standard locale seat: the entry declares the namespace
// and apply registered both dictionaries.
expect(before.slots.entries('sidebar.workspaces')[0]!.locale).toBe('workspace')
expect(before.locale.bind('workspace')('session.new')).toBe('新会话')
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()

View File

@@ -15,6 +15,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 } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
const SID = 's1' as SessionId
@@ -22,6 +23,15 @@ const SID = 's1' as SessionId
afterEach(cleanup)
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)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
return runtime
}
/** Test-owned sidebar shell role: declares and renders the browsing region. */
type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
function SidebarFrame({ renderSlot }: FrameProps) {
@@ -30,7 +40,7 @@ function SidebarFrame({ renderSlot }: FrameProps) {
describe('session rename through the assembled browser', () => {
it('renames via the row menu: binding.session.rename fires, the dialog closes, the row re-labels from the list', async () => {
const runtime = await SlotTestRuntime.create()
const runtime = await createRuntime()
const rename = vi.fn<ISession['rename']>(async title => ({
ok: true, value: { title: title.trim().replace(/\s+/g, ' '), seq: 7 },
}))
@@ -54,20 +64,20 @@ describe('session rename through the assembled browser', () => {
// The current session's group auto-expands; open the row's action menu.
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
fireEvent.click(within(row as HTMLElement).getByLabelText('会话“旧标题”的操作'))
fireEvent.click(view.getByRole('menuitem', { name: '重命名', hidden: true }))
// The dialog seeds from the current title; submit a padded value.
const input = await view.findByLabelText('Session name') as HTMLInputElement
const input = await view.findByLabelText('会话名称') as HTMLInputElement
expect(input.value).toBe('旧标题')
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
fireEvent.click(view.getByRole('button', { name: '重命名' }))
// The injected hop reached the session face with the edge-trimmed draft
// (the dialog trims edges; interior normalization is host-side).
await waitFor(() => { expect(rename).toHaveBeenCalledWith('分叉 实验记录') })
// Acceptance closes the dialog without any push-frame wait.
await waitFor(() => { expect(view.queryByLabelText('Session name')).toBeNull() })
await waitFor(() => { expect(view.queryByLabelText('会话名称')).toBeNull() })
// The manager lands the unary echo in the list store (its own package
// tests own that hop); the row re-labels from list state alone.
await runtime.sessions.updateSummary(SID, { displayTitle: '分叉 实验记录', title: '分叉 实验记录' })
@@ -77,7 +87,7 @@ describe('session rename through the assembled browser', () => {
})
it('a rejected rename keeps the dialog open with the error surfaced', async () => {
const runtime = await SlotTestRuntime.create()
const runtime = await createRuntime()
const rename = vi.fn<ISession['rename']>(async () => ({
ok: false, error: { code: 'internal', message: 'title write failed', details: {} },
}))
@@ -101,17 +111,17 @@ describe('session rename through the assembled browser', () => {
await runtime.flush()
const row = (await view.findByText('旧标题')).closest('[role="treeitem"]')!
fireEvent.click(within(row as HTMLElement).getByLabelText('Session actions for 旧标题'))
fireEvent.click(view.getByRole('menuitem', { name: 'Rename', hidden: true }))
const input = await view.findByLabelText('Session name')
fireEvent.click(within(row as HTMLElement).getByLabelText('会话“旧标题”的操作'))
fireEvent.click(view.getByRole('menuitem', { name: '重命名', hidden: true }))
const input = await view.findByLabelText('会话名称')
fireEvent.change(input, { target: { value: '新名' } })
fireEvent.click(view.getByRole('button', { name: 'Rename' }))
fireEvent.click(view.getByRole('button', { name: '重命名' }))
// Failure: the injected hop rethrows the business error; the dialog
// stays open with the alert and the row keeps its title.
const alert = await view.findByRole('alert')
expect(alert.textContent).toContain('title write failed')
expect(view.getByLabelText('Session name')).toBeTruthy()
expect(view.getByLabelText('会话名称')).toBeTruthy()
expect(view.getByText('旧标题')).toBeTruthy()
await runtime.dispose()
})

View File

@@ -2,12 +2,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
import { ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
// Standard locale seat stub mirroring the real ns → common → key chain (zh default).
const t = makeTranslate(zh, commonZh) as never
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
@@ -45,11 +51,11 @@ describe('workspace browser rows', () => {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 1, expanded: true, containsCurrent: true, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} t={t} />)
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.getByText('1 个会话')).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
expect(onCreate).toHaveBeenCalledOnce()
expect(onToggle).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Project'))
@@ -58,18 +64,18 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', running: true, updatedAt: 0,
id: sid('session'), title: 'Session', blank: false, running: true, updatedAt: 0,
}
const onOpen = vi.fn()
render(
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
onRename={vi.fn()} onFork={vi.fn()} />,
onRename={vi.fn()} onFork={vi.fn()} t={t} />,
)
const row = screen.getByRole('treeitem')
expect(row.getAttribute('aria-selected')).toBe('true')
expect(row.hasAttribute('aria-expanded')).toBe(false)
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
fireEvent.click(row)
expect(onOpen).toHaveBeenCalledWith(node.id)
})
@@ -84,22 +90,22 @@ describe('workspace browser rows', () => {
}
render(<ProjectRowItem
group={group} onToggle={onToggle} onCreate={vi.fn()}
actions={{ rename: onRename, delete: onDelete }}
actions={{ rename: onRename, delete: onDelete }} t={t}
/>)
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
// Opening the menu neither toggles the group nor renames yet.
expect(onToggle).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/)
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(screen.getByRole('menuitem', { name: '删除工作区' }).className).toMatch(/danger/)
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
expect(onRename).toHaveBeenCalledOnce()
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledOnce()
expect(onDelete).toHaveBeenCalledOnce()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
@@ -111,13 +117,13 @@ describe('workspace browser rows', () => {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
// Card body: full title + cwd + absolute creation time.
expect(screen.getAllByText('Project')).toHaveLength(2)
expect(screen.getByText('/projects/project')).toBeTruthy()
expect(screen.getByText(/^Created /)).toBeTruthy()
expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
} finally {
vi.useRealTimers()
}
@@ -128,8 +134,8 @@ describe('workspace browser rows', () => {
key: '', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: 'Ungrouped',
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t} />)
expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
})
it('session row menu opens without opening the session and dispatches rename and fork', () => {
@@ -137,27 +143,27 @@ describe('workspace browser rows', () => {
const onRename = vi.fn()
const onFork = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', running: false, updatedAt: 0,
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} />)
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
onRename={onRename} onFork={onFork} t={t} />)
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/)
// Rename dispatches with the current display title (dialog prefill).
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
expect(onFork).toHaveBeenCalledWith(node.id)
// Delete stays visual-only.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' }))
expect(onRename).toHaveBeenCalledOnce()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
@@ -166,23 +172,23 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', running: true, updatedAt: 0,
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
// Card body: full title + relative time + running status.
expect(screen.getAllByText('Hovered')).toHaveLength(2)
expect(screen.getByText('1min ago')).toBeTruthy()
expect(screen.getByText('Running')).toBeTruthy()
expect(screen.getByText('1分钟前')).toBeTruthy()
expect(screen.getByText('进行中')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
// Menu open (disabled=true) suppresses the card for the same hover.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
fireEvent.click(screen.getByRole('button', { name: '会话“Hovered”的操作' }))
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('1min ago')).toBeNull()
expect(screen.queryByText('1分钟前')).toBeNull()
} finally {
vi.useRealTimers()
}
@@ -192,14 +198,14 @@ describe('workspace browser rows', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', running: false, updatedAt: 0,
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} />)
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
expect(screen.getByText('now ago')).toBeTruthy()
expect(screen.getByText('空闲')).toBeTruthy()
expect(screen.getAllByText('刚刚')).toHaveLength(2)
} finally {
vi.useRealTimers()
}
@@ -207,12 +213,12 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', running: false, updatedAt: 0,
id: sid('s1'), title: 'Drag me', blank: false, running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} />,
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} t={t} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
@@ -230,7 +236,7 @@ describe('workspace browser rows', () => {
const active = dragProps({ active: true, marker: 'before' })
rerender(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={active} />,
onRename={vi.fn()} onFork={vi.fn()} drag={active} t={t} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
@@ -244,7 +250,7 @@ describe('workspace browser rows', () => {
const after = dragProps({ active: true, marker: 'after' })
rerender(
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} drag={after} />,
onRename={vi.fn()} onFork={vi.fn()} drag={after} t={t} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
import { deriveFlat, deriveGroups, projectLabel, relativeTime, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
const sid = (id: string) => id as SessionId
@@ -52,26 +52,35 @@ describe('deriveGroups', () => {
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
expect(groups[0]!.sessions.find(session => session.id === currentBlank.id)!.title).toBe('New Session')
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
// The stored placeholder title stays canonical; the renderer swaps in
// the localized New Session label via the blank flag.
expect(blankNode.title).toBe('New Session')
expect(blankNode.blank).toBe(true)
expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
expect(groups[0]!.sessionCount).toBe(2)
// A non-current blank stray never surfaces an Ungrouped bucket either.
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view())
expect(strayGroups.map(group => group.key)).toEqual(['first'])
})
it('searches the current blank session by its New Session title', () => {
it('excludes blank sessions from search regardless of the query', () => {
const currentBlank = { ...summary('opaque-current', 5), blank: true }
const staleBlank = { ...summary('new session stale', 4), blank: true }
const real = { ...summary('real', 3), displayTitle: 'new session notes' }
const sessions = {
...list(currentBlank, staleBlank),
...list(currentBlank, staleBlank, real),
current: currentBlank.id,
}
const groups = deriveGroups(
sessions, [workspace('first', ['opaque-current', 'new session stale'])], view([], 'new session'),
sessions,
[workspace('first', ['opaque-current', 'new session stale', 'real'])],
view([], 'new session'),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([currentBlank.id])
expect(groups[0]!.sessions[0]!.title).toBe('New Session')
expect(groups[0]!.sessionCount).toBe(1)
// Only the real title hit matches; the current blank's placeholder title
// never participates (it displays localized, so matching it would tie
// search to one language).
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id])
})
it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => {
@@ -168,7 +177,7 @@ describe('deriveFlat', () => {
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
})
it('shows only the current blank session with its New Session title', () => {
it('shows only the current blank session and excludes blanks from search', () => {
const currentBlank = { ...summary('current-blank', 9), blank: true }
const staleBlank = { ...summary('stale-blank', 8), blank: true }
const sessions = {
@@ -178,7 +187,10 @@ describe('deriveFlat', () => {
const rows = deriveFlat(sessions, { query: '' })
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
expect(deriveFlat(sessions, { query: 'new session' }).map(row => row.id)).toEqual([currentBlank.id])
expect(rows.map(row => row.blank)).toEqual([true, false])
// Blank rows never match a query — not their placeholder title, not their id.
expect(deriveFlat(sessions, { query: 'new session' })).toEqual([])
expect(deriveFlat(sessions, { query: 'current-blank' })).toEqual([])
expect(deriveFlat(sessions, { query: 'stale-blank' })).toEqual([])
})
})
@@ -202,14 +214,14 @@ describe('projectLabel', () => {
})
})
describe('formatRelativeTime', () => {
it('formats current, minute, hour, day, month, and year buckets', () => {
describe('relativeTime', () => {
it('buckets current, minute, hour, day, month, and year distances', () => {
const now = 400 * 24 * 60 * 60 * 1_000
expect(formatRelativeTime(now, now)).toBe('now')
expect(formatRelativeTime(now - 5 * 60_000, now)).toBe('5min')
expect(formatRelativeTime(now - 3 * 3_600_000, now)).toBe('3h')
expect(formatRelativeTime(now - 2 * 86_400_000, now)).toBe('2d')
expect(formatRelativeTime(now - 60 * 86_400_000, now)).toBe('2mo')
expect(formatRelativeTime(0, now)).toBe('1y')
expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 })
expect(relativeTime(now - 5 * 60_000, now)).toEqual({ unit: 'minutes', n: 5 })
expect(relativeTime(now - 3 * 3_600_000, now)).toEqual({ unit: 'hours', n: 3 })
expect(relativeTime(now - 2 * 86_400_000, now)).toEqual({ unit: 'days', n: 2 })
expect(relativeTime(now - 60 * 86_400_000, now)).toEqual({ unit: 'months', n: 2 })
expect(relativeTime(0, now)).toEqual({ unit: 'years', n: 1 })
})
})

View File

@@ -5,13 +5,20 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
// The seat's key domain is workspace common; the stub mirrors the real
// lookup chain (namespace, then common vocabulary, then the key).
const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh)
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
@@ -63,6 +70,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
createWorkspace: vi.fn(async () => workspace('created', [])),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
t,
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)
@@ -82,29 +90,29 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
})
expect(screen.getByText('Workspaces')).toBeTruthy()
expect(screen.getByText('工作区')).toBeTruthy()
expect(screen.getByText('alpha')).toBeTruthy()
// Sessions hidden while their group is folded.
expect(screen.queryByText('alpha-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label
fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' }))
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
expect(b.store.getSnapshot().groupBy).toBe('flat')
expect(screen.getByText('Sessions')).toBeTruthy()
expect(screen.getByText('会话')).toBeTruthy()
expect(screen.queryByText('alpha')).toBeNull()
expect(screen.getByText('alpha-s')).toBeTruthy()
expect(screen.getByText('beta-s')).toBeTruthy()
// Back to workspace grouping through the same menu.
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' }))
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' }))
expect(b.store.getSnapshot().groupBy).toBe('workspace')
expect(screen.getByText('Workspaces')).toBeTruthy()
expect(screen.getByText('工作区')).toBeTruthy()
// Escape closes the menu without picking.
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(b.store.getSnapshot().groupBy).toBe('workspace')
@@ -134,7 +142,7 @@ describe('WorkspaceBrowser', () => {
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.getByText('child-s')).toBeTruthy()
expect(screen.queryByRole('button', { name: /Expand|Collapse/ })).toBeNull()
expect(screen.queryByRole('button', { name: /展开|收起/ })).toBeNull()
expect(screen.getByText('child-s').closest('[role="treeitem"]')?.getAttribute('draggable')).toBe('true')
})
@@ -147,7 +155,7 @@ describe('WorkspaceBrowser', () => {
})
// The current-group effect expanded the owning group without a click.
expect(screen.getByText('alpha-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
})
@@ -160,8 +168,8 @@ describe('WorkspaceBrowser', () => {
})
// The loose session's group is UNGROUPED_KEY: expanded by the effect.
expect(screen.getByText('loose')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
expect(screen.queryByRole('button', { name: '工作区“未分组”的操作' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '在“未分组”中新建会话' }))
expect(startSession).not.toHaveBeenCalled()
})
@@ -180,7 +188,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('b')).toBeNull()
})
it('shows only the current blank session as New Session in grouped, flat, and search modes', () => {
it('shows only the current blank session as the localized New Session, excluded from search', () => {
const currentBlank = summary('alpha-blank', 9, { blank: true })
const staleBlank = summary('beta-blank', 8, { blank: true })
const sessions = sessionState(
@@ -193,18 +201,22 @@ describe('WorkspaceBrowser', () => {
workspace('alpha', ['alpha-blank']), workspace('beta', ['beta-blank']),
])),
})
expect(screen.getByText('New Session')).toBeTruthy()
expect(screen.getByText('新会话')).toBeTruthy()
expect(screen.queryByText('alpha-blank')).toBeNull()
expect(screen.queryByText('beta-blank')).toBeNull()
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.getByText('1 个会话')).toBeTruthy()
rerender(b, { useSessions: hook({ ...sessions, current: staleBlank.id }) })
expect(screen.getAllByText('New Session')).toHaveLength(1)
expect(screen.getAllByText('新会话')).toHaveLength(1)
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getAllByText('New Session')).toHaveLength(1)
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'new session' } })
expect(screen.getAllByText('New Session')).toHaveLength(1)
expect(screen.getAllByText('新会话')).toHaveLength(1)
// Search excludes blank rows entirely — neither the canonical stored
// title nor the localized display label participates in matching.
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } })
expect(screen.queryByText('新会话')).toBeNull()
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } })
expect(screen.queryByText('新会话')).toBeNull()
})
it('searches across groups, clears via the clear button, and shows the empty states', () => {
@@ -216,14 +228,14 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
})
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
fireEvent.change(input, { target: { value: 'needle' } })
// Search forces matches visible without expansion state.
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
fireEvent.change(input, { target: { value: 'zzz' } })
expect(screen.getByText('No matches')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
expect(screen.getByText('无匹配结果')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
expect(input.value).toBe('')
// Clicking the field row focuses the input (wide mode).
fireEvent.click(input.parentElement as HTMLElement)
@@ -232,13 +244,13 @@ describe('WorkspaceBrowser', () => {
it('shows the no-sessions empty state in both modes', () => {
const b = mount()
expect(screen.getByText('No sessions yet')).toBeTruthy()
expect(screen.getByText('暂无会话')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('No sessions yet')).toBeTruthy()
expect(screen.getByText('暂无会话')).toBeTruthy()
// Flat search misses show No matches.
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
expect(screen.getByText('No matches')).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
expect(screen.getByText('无匹配结果')).toBeTruthy()
})
it('rail state renders icon controls that request expansion', () => {
@@ -247,17 +259,17 @@ describe('WorkspaceBrowser', () => {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar })
// No wide chrome in rail state.
expect(screen.queryByText('Workspaces')).toBeNull()
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(screen.queryByText('工作区')).toBeNull()
expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
// The wide flip mounts the input and focuses it after the slide.
rerender(b, { wide: true })
const input = screen.getByPlaceholderText('Search name, keywords...')
const input = screen.getByPlaceholderText('搜索名称、关键词…')
act(() => { vi.advanceTimersByTime(300) })
expect(document.activeElement).toBe(input)
// Wide search button is decorative (tabIndex -1, no expand call).
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
@@ -267,17 +279,17 @@ describe('WorkspaceBrowser', () => {
it('rail create-workspace toggles the create-only picker in place, without expanding', () => {
const expandSidebar = vi.fn()
mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(expandSidebar).not.toHaveBeenCalled()
// createOnly: existing workspaces are not listed, only the create actions.
expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull()
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
// Toggle: open and close in place.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.queryByRole('menu')).toBeNull()
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
@@ -398,20 +410,20 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked.
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } })
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
// A duplicate of another workspace's title shows the inline conflict.
fireEvent.change(input, { target: { value: ' Beta ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Rename' }).disabled).toBe(true)
expect(screen.getByRole('alert').textContent).toBe('已存在名为“Beta”的工作区。')
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重命名' }).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
fireEvent.click(screen.getByRole('button', { name: '重命名' }))
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
// While renaming: input disabled, close blocked, Enter ignored.
expect(input.disabled).toBe(true)
@@ -427,9 +439,9 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
const input = screen.getByLabelText<HTMLInputElement>('工作区名称')
// Enter with a blocked draft (unchanged) does nothing.
fireEvent.keyDown(input, { key: 'Enter' })
expect(renameWorkspace).not.toHaveBeenCalled()
@@ -441,7 +453,7 @@ describe('WorkspaceBrowser', () => {
// The dialog stays for retry; typing clears the error; Cancel closes.
fireEvent.change(input, { target: { value: 'Renamed2' } })
expect(screen.queryByRole('alert')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
@@ -451,10 +463,10 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
fireEvent.change(screen.getByLabelText('工作区名称'), { target: { value: 'Other' } })
fireEvent.click(screen.getByRole('button', { name: '重命名' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
})
@@ -465,31 +477,31 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', ['session'], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
const dialog = screen.getByRole('dialog', { name: 'Delete workspace' })
expect(dialog.textContent).toContain('removes “Alpha” from the workspace list')
expect(dialog.textContent).toContain('folder and session logs will be kept')
expect(dialog.textContent).toContain('sessions will appear under Ungrouped')
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
const dialog = screen.getByRole('dialog', { name: '删除工作区' })
expect(dialog.textContent).toContain('将把“Alpha”从工作区列表中移除')
expect(dialog.textContent).toContain('文件夹与会话记录会保留')
expect(dialog.textContent).toContain('其会话将显示在“未分组”下')
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: 'Delete workspace' })
const confirm = screen.getByRole<HTMLButtonElement>('button', { name: '删除工作区' })
fireEvent.click(confirm)
fireEvent.click(confirm)
expect(deleteWorkspace).toHaveBeenCalledOnce()
expect(deleteWorkspace).toHaveBeenCalledWith(wid('alpha'))
expect(confirm.disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Cancel' }).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('Deleting workspace…')
expect(screen.getByRole<HTMLButtonElement>('button', { name: '取消' }).disabled).toBe(true)
expect(screen.getByRole('status').textContent).toBe('正在删除工作区…')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
await act(async () => { resolveDelete() })
// RPC success alone does not close: the component waits until its
// useWorkspaces projection has committed the removal, preventing a stale
// duplicate-name frame from leaking into the next create gesture.
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
rerender(browser, { useWorkspaces: hook(workspaceState([])) })
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
})
it('keeps the delete dialog open on failure and allows retry or cancellation', async () => {
@@ -500,15 +512,15 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
deleteWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('storage unavailable') })
expect(screen.getByRole('dialog', { name: 'Delete workspace' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Delete workspace' }))
expect(screen.getByRole('dialog', { name: '删除工作区' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '删除工作区' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
})
it('Cancel, Escape, and Close dismiss deletion without calling the action', () => {
@@ -518,17 +530,17 @@ describe('WorkspaceBrowser', () => {
deleteWorkspace,
})
const open = () => {
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
fireEvent.click(screen.getByRole('button', { name: '工作区“Alpha”的操作' }))
fireEvent.click(screen.getByRole('menuitem', { name: '删除工作区' }))
}
open()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByRole('button', { name: '取消' }))
open()
fireEvent.keyDown(document, { key: 'Escape' })
open()
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
fireEvent.click(screen.getByRole('button', { name: '关闭' }))
expect(deleteWorkspace).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog', { name: 'Delete workspace' })).toBeNull()
expect(screen.queryByRole('dialog', { name: '删除工作区' })).toBeNull()
})
it('search hides drag affordances (rows are not draggable during search)', () => {
@@ -537,7 +549,7 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
})
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } })
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
expect(row.getAttribute('draggable')).toBe('false')
})

View File

@@ -5,12 +5,19 @@ import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
import { zh } from '../src/client/locales.ts'
afterEach(cleanup)
// The seat's key domain is workspace common; the stub mirrors the real
// lookup chain (namespace, then common vocabulary, then the key).
const t: WorkspacePickerProps['t'] = makeTranslate(zh, commonZh)
const wid = (id: string) => id as WorkspaceId
function workspace(id: string, title = id): WorkspaceView {
return {
@@ -91,6 +98,7 @@ function mount(
createWorkspace={createWorkspace}
useDirectoryFlow={occupancy.useDirectoryFlow}
renderSlot={renderSlot}
t={t}
/>
)
const view = render(
@@ -102,7 +110,7 @@ function mount(
}
}
function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void {
function chooseItem(name: '打开本地文件夹…' | '新建工作区'): void {
fireEvent.click(screen.getByRole('menuitem', { name }))
}
@@ -117,10 +125,10 @@ describe('WorkspacePicker', () => {
const created = workspace('new', 'New')
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
chooseItem('新建工作区')
const input = screen.getByLabelText('新工作区名称')
fireEvent.change(input, { target: { value: 'project-one' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
@@ -130,7 +138,7 @@ describe('WorkspacePicker', () => {
const createWorkspace = vi.fn(async () => created)
const b = mount([], createWorkspace)
expect(screen.queryByTestId('directory-flow')).toBeNull()
chooseItem('Open local folder…')
chooseItem('打开本地文件夹…')
expect(b.onClose).toHaveBeenCalled()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
@@ -142,7 +150,7 @@ describe('WorkspacePicker', () => {
it('treats flow cancellation as a silent no-op', () => {
const b = mount([])
chooseItem('Open local folder…')
chooseItem('打开本地文件夹…')
act(() => { b.probe.owner!.onCancel() })
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
@@ -157,15 +165,15 @@ describe('WorkspacePicker', () => {
})
})
const b = mount([], createWorkspace)
chooseItem('Open local folder…')
chooseItem('打开本地文件夹…')
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
expect(screen.getByRole('alert').textContent).toBe('请选择其他名称的文件夹。')
// The failed adoption withdrew the flow; Choose again reopens it.
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
fireEvent.click(screen.getByRole('button', { name: '重新选择' }))
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})
@@ -175,22 +183,22 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('adopted')
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
chooseItem('Open local folder…')
chooseItem('打开本地文件夹…')
// The flow is open but nothing is picked yet: a chooser pending on the
// host display must already block concurrent workspace actions.
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
act(() => { b.probe.owner!.onPicked('/tmp/project') })
expect(b.probe.owner!.busy).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '打开本地文件夹…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(true)
await act(async () => { resolve(created); await pending })
expect(b.probe.owner!.busy).toBe(false)
})
it('shows the flow-reported failure in the folder-error surface', () => {
const b = mount([])
chooseItem('Open local folder…')
chooseItem('打开本地文件夹…')
act(() => { b.probe.owner!.onError('no chooser installed') })
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
expect(screen.queryByTestId('directory-flow')).toBeNull()
@@ -199,18 +207,18 @@ describe('WorkspacePicker', () => {
it('closes a creation modal when the user cancels', () => {
mount([])
chooseItem('Create a new workspace')
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
chooseItem('新建工作区')
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('blocks a create-new name already present in the Workspace list', () => {
const b = mount([workspace('alpha', 'Alpha')])
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.')
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Create workspace' }).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('New workspace name'), { key: 'Enter' })
chooseItem('新建工作区')
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: ' Alpha ' } })
expect(screen.getByRole('alert').textContent).toBe('已存在名为“Alpha”的工作区。')
expect(screen.getByRole<HTMLButtonElement>('button', { name: '创建工作区' }).disabled).toBe(true)
fireEvent.keyDown(screen.getByLabelText('新工作区名称'), { key: 'Enter' })
expect(b.createWorkspace).not.toHaveBeenCalled()
})
@@ -219,12 +227,12 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('fresh', 'same-name')
const b = mount([], vi.fn(() => pending))
chooseItem('Create a new workspace')
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
chooseItem('新建工作区')
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'same-name' } })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
b.rerenderItems([created])
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
expect(screen.queryByRole('alert')).toBeNull()
await act(async () => { resolve(created); await pending })
expect(b.onPick).toHaveBeenCalledWith(created.workspaceId)
@@ -235,12 +243,12 @@ describe('WorkspacePicker', () => {
const pending = new Promise<WorkspaceView>((_resolve, rejectPromise) => { reject = rejectPromise })
const createWorkspace = vi.fn(() => pending)
const b = mount([], createWorkspace)
chooseItem('Create a new workspace')
const input = screen.getByLabelText('New workspace name')
chooseItem('新建工作区')
const input = screen.getByLabelText('新工作区名称')
fireEvent.keyDown(input, { key: 'ArrowRight' })
fireEvent.change(input, { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('status').textContent).toBe('Creating workspace…')
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
expect(screen.getByRole('status').textContent).toBe('正在创建工作区…')
fireEvent.keyDown(input, { key: 'Enter' })
expect(createWorkspace).toHaveBeenCalledTimes(1)
fireEvent.keyDown(document, { key: 'Escape' })
@@ -252,10 +260,10 @@ describe('WorkspacePicker', () => {
it('reports non-Error creation failures', async () => {
const b = mount([], vi.fn(async () => { throw 'permission denied' }))
chooseItem('Create a new workspace')
chooseItem('新建工作区')
// The name field starts empty (no prefill); a name is required to submit.
fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'broken' } })
fireEvent.click(screen.getByRole('button', { name: '创建工作区' }))
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied')
})
@@ -268,7 +276,7 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -283,49 +291,49 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
expect(screen.getByRole('status').textContent).toBe('正在加载工作区…')
})
it('hides the folder entry while the directory-flow hole is empty', () => {
mount([], vi.fn(), occupancySource(false))
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
expect(screen.getByRole('menuitem', { name: '新建工作区' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
})
it('shows the folder entry when a flow package activates after the first paint', () => {
const b = mount([], vi.fn(), occupancySource(false))
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
// Registration changes flow through the subscription, no re-render needed.
act(() => { b.occupancy.flip(true) })
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy()
})
it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
chooseItem('Open local folder…')
chooseItem('打开本地文件夹…')
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldnt open folder' })).toBeTruthy() })
await waitFor(() => { expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy() })
// The occupant unloads while the error dialog is up: retrying would open
// a flow nobody can serve or cancel, so the button goes inert.
act(() => { b.occupancy.flip(false) })
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Choose again' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: '重新选择' }).disabled).toBe(true)
// Cancel stays the way out, and the menu actions are usable again.
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
})
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
const b = mount([])
chooseItem('Open local folder…')
chooseItem('打开本地文件夹…')
expect(screen.getByTestId('directory-flow')).toBeTruthy()
// The flow plugin unloads mid-interaction (HMR): nobody is left to
// cancel, so the owner withdraws and the actions come back.
act(() => { b.occupancy.flip(false) })
expect(b.probe.owner!.open).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: '新建工作区' }).disabled).toBe(false)
expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull()
})
})