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

View File

@@ -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>