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:
@@ -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' : 'Couldn’t 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': 'Couldn’t 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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user