Merge remote-tracking branch 'origin/master' into mergebot/pr711

# Conflicts:
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/client/ui-workspace/src/client/index.ts
#	packages/client/ui-workspace/src/client/rows/Rows.tsx
#	packages/client/ui-workspace/src/client/tree.ts
#	packages/client/ui-workspace/tests/apply.spec.ts
#	packages/client/ui-workspace/tests/rows.spec.tsx
#	packages/client/ui-workspace/tests/tree.spec.ts
#	packages/client/ui-workspace/tests/workspace-browser.spec.tsx
This commit is contained in:
imccyu
2026-07-31 04:37:08 +08:00
243 changed files with 3584 additions and 1336 deletions
@@ -44,28 +44,27 @@ function sanitizeSearchQuery(value: string): string {
return withoutNul.slice(0, end)
}
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. */
@@ -80,7 +79,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 />
@@ -100,7 +99,7 @@ interface DragState {
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore'
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
> & {
workspaces: readonly WorkspaceView[]
/** Open the browser-owned rename dialog for a real Workspace group. */
@@ -114,7 +113,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,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
@@ -137,9 +136,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}>No sessions yet</div>
<div className={css.empty}>{t('empty.none')}</div>
)}
{groups.map(group => (
// Group section: header row + expanded top-level session rows. The
@@ -148,6 +147,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)
@@ -210,6 +210,7 @@ function SessionTree({
onRename={onSessionRename}
onFork={forkSession}
drag={dragProps}
t={t}
/>
)
})}
@@ -222,15 +223,15 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, forkSession, onSessionRename }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename'>) {
function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list), [list])
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}>No sessions yet</div>
<div className={css.empty}>{t('empty.none')}</div>
)}
{rows.map(node => (
<SessionNodeItem
@@ -241,6 +242,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename }: Pick<Sess
onOpen={open}
onRename={onSessionRename}
onFork={forkSession}
t={t}
/>
))}
</div>
@@ -264,7 +266,8 @@ function SearchResults({
query,
remote,
resultLimit,
}: Pick<SessionTreeProps, 'useSessions' | 'open'> & {
t,
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
workspaces: readonly WorkspaceView[]
query: string
remote: RemoteSearchState
@@ -284,7 +287,7 @@ function SearchResults({
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list}>
<div className={css.searchTree} role="tree" aria-label="Search results">
<div className={css.searchTree} role="tree" aria-label={t('search.results.aria')}>
{results.items.map(result => (
<SearchResultItem
key={result.id}
@@ -295,19 +298,19 @@ function SearchResults({
))}
</div>
{pending && (
<div className={css.searchStatus} role="status">Searching session history</div>
<div className={css.searchStatus} role="status">{t('search.pending')}</div>
)}
{failed && (
<div className={css.searchWarning} role="status">
Content search is temporarily unavailable. Showing name matches.
{t('search.unavailable')}
</div>
)}
{!pending && results.items.length === 0 && (
<div className={css.empty}>No matching sessions</div>
<div className={css.empty}>{t('search.noMatches')}</div>
)}
{results.hasMore && (
<div className={css.searchStatus}>
Showing the first {resultLimit} results. Narrow your search.
{t('search.hasMore', { n: resultLimit })}
</div>
)}
</div>
@@ -340,6 +343,7 @@ export function WorkspaceBrowser({
searchResultLimit,
useDirectoryFlow,
renderSlot,
t,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -511,16 +515,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)
}}
@@ -530,6 +534,7 @@ export function WorkspaceBrowser({
</Tooltip>
{/* Picker menu + create dialogs (same package — direct composition). */}
<WorkspaceCreateFlow
t={t}
open={wsPickerOpen}
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
@@ -549,11 +554,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() } }}
>
@@ -565,7 +570,7 @@ export function WorkspaceBrowser({
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search names or content…"
placeholder={t('search.placeholder')}
maxLength={SEARCH_QUERY_MAX_CODE_UNITS}
value={query}
onChange={(e) => { setQuery(sanitizeSearchQuery(e.target.value)) }}
@@ -575,7 +580,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 />
@@ -595,10 +600,16 @@ export function WorkspaceBrowser({
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
t={t}
/>
)
: groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} forkSession={forkSession} onSessionRename={onSessionRename} />
? (
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} t={t}
/>
)
: (
<SessionTree
useSessions={useSessions}
@@ -608,6 +619,7 @@ export function WorkspaceBrowser({
startSession={startSession}
open={open}
insertSessionBefore={insertSessionBefore}
t={t}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
@@ -624,18 +636,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() }}
@@ -650,7 +663,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>
@@ -658,18 +671,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() }}
@@ -688,25 +702,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>
@@ -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}
@@ -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'
@@ -123,13 +123,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
@@ -142,12 +143,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'>
@@ -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,11 +52,14 @@ 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')
const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
const result = await ctx.sessions.search(query, signal)
if (!result.ok) throw new Error(result.error.message)
return result.value
}
// 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> => ({
@@ -100,6 +117,7 @@ export function apply(ctx: ClientContext): void {
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
locale: NS,
},
WorkspaceBrowser,
)),
@@ -109,6 +127,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,
)),
@@ -0,0 +1,128 @@
/**
* `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': '清除搜索',
'search.results.aria': '搜索结果',
'search.pending': '正在搜索会话历史…',
'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。',
'search.noMatches': '无匹配会话',
'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。',
'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',
'search.results.aria': 'Search results',
'search.pending': 'Searching session history…',
'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.',
'search.noMatches': 'No matching sessions',
'search.hasMore': 'Showing the first {n} results. Narrow your 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>
@@ -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, SearchResultNode, 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>
)
@@ -212,7 +243,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
@@ -223,10 +254,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
@@ -262,13 +300,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)
@@ -280,7 +318,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 />
@@ -293,7 +331,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}
/>
)
+32 -12
View File
@@ -16,7 +16,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
}
@@ -92,7 +95,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
}
@@ -150,6 +157,7 @@ function sessionNode(s: SessionSummary): SessionNode {
return {
id: s.id,
title: sessionTitle(s),
blank: s.blank,
running: s.running,
updatedAt: s.updatedAt,
}
@@ -214,6 +222,15 @@ export function deriveFlat(list: SessionListState): SessionNode[] {
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
}
/**
* Merge immediate title/Workspace substring matches with ranked Host content
* matches. Local rows lead newest-first, content-only rows retain backend
@@ -251,7 +268,9 @@ export function deriveSearchResults(
const local: SessionSummary[] = []
for (const id of list.ids) {
const summary = list.byId[id]
if (summary === undefined || !sessionVisible(summary, list.current)) continue
// Blank placeholders never match a query (their canonical title displays
// localized, so matching it would tie search to one language).
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
if (
sessionTitle(summary).toLowerCase().includes(q)
|| labelOf(summary).toLowerCase().includes(q)
@@ -271,7 +290,7 @@ export function deriveSearchResults(
for (const summary of local) include(summary)
for (const item of content.items) {
const summary = list.byId[item.sessionId]
if (summary !== undefined && sessionVisible(summary, list.current)) include(summary)
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
}
return {
@@ -290,20 +309,21 @@ export function deriveSearchResults(
}
/**
* 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)) }
}
@@ -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 = () => {}