Merge remote-tracking branch 'origin/master' into codex/basic-session-search

# Conflicts:
#	packages/client/runtime/README.i18n.yaml
#	packages/client/ui-workspace/README.i18n.yaml
#	packages/client/ui-workspace/README.md
#	packages/client/ui-workspace/README.zh.md
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/tests/client-handler.spec.ts
This commit is contained in:
Hypatia May
2026-07-27 17:17:31 +08:00
110 changed files with 2670 additions and 474 deletions
@@ -274,6 +274,16 @@
color: var(--dsw-alias-state-error-primary);
}
.deleteAction:not(:disabled) {
color: var(--dsw-alias-state-error-primary);
}
.deleteStatus {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
@media (prefers-reduced-motion: reduce) {
.wide {
animation: none;
@@ -102,10 +102,15 @@ type SessionTreeProps = Pick<
workspaces: readonly WorkspaceView[]
/** Open the browser-owned rename dialog for a real Workspace group. */
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, startSession, open, workspaces, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
function SessionTree({
useSessions, startSession, open, workspaces,
onRenameRequest, onDeleteRequest, insertSessionBefore,
}: SessionTreeProps) {
const list = useSessions((s) => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
@@ -143,11 +148,17 @@ function SessionTree({ useSessions, startSession, open, workspaces, onRenameRequ
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
onRename={group.workspaceId === undefined
actions={group.workspaceId === undefined
? undefined
: () => {
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
: {
rename: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
},
delete: () => {
/* v8 ignore next -- narrowing guard: the actions object exists only for real-workspace groups. */
if (group.workspaceId !== undefined) onDeleteRequest(group.workspaceId, group.label)
},
}}
/>
{group.sessions.map((node, index) => {
@@ -312,6 +323,7 @@ export function WorkspaceBrowser({
startSession,
open,
renameWorkspace,
deleteWorkspace,
insertSessionBefore,
createWorkspace,
searchSessions,
@@ -412,6 +424,41 @@ export function WorkspaceBrowser({
})
}
// Delete dialog is separate from the row so a successful removal can
// unmount that row without tearing down the in-flight confirmation state.
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteCommittedId, setDeleteCommittedId] = useState<WorkspaceId | null>(null)
const [deleteError, setDeleteError] = useState<string | null>(null)
useEffect(() => {
if (deleteCommittedId === null
|| workspaces.some(workspace => workspace.workspaceId === deleteCommittedId)) return
setDeleting(false)
setDeleteCommittedId(null)
setDeleteTarget(null)
}, [deleteCommittedId, workspaces])
const closeDelete = () => {
if (deleting) return
setDeleteTarget(null)
setDeleteError(null)
}
const confirmDelete = () => {
/* v8 ignore next -- the Modal is absent without a target and its button is disabled while deleting. */
if (deleting || deleteTarget === null) return
setDeleting(true)
setDeleteCommittedId(null)
setDeleteError(null)
deleteWorkspace(deleteTarget.workspaceId).then(() => {
// Keep the confirmation pending until this component has rendered the
// committed list projection without the deleted id. Closing earlier
// exposes one stale React frame to the next Create Workspace gesture.
setDeleteCommittedId(deleteTarget.workspaceId)
}).catch((reason: unknown) => {
setDeleting(false)
setDeleteError(reason instanceof Error ? reason.message : String(reason))
})
}
return (
<div className={clsx(css.root, !wide && css.rail)}>
<div className={css.sectionHeader}>
@@ -513,6 +560,10 @@ export function WorkspaceBrowser({
setRenameDraft(currentTitle)
setRenameError(null)
}}
onDeleteRequest={(workspaceId, title) => {
setDeleteTarget({ workspaceId, title })
setDeleteError(null)
}}
/>
))}
</div>
@@ -547,6 +598,30 @@ export function WorkspaceBrowser({
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
<Modal
open={deleteTarget !== null}
onClose={closeDelete}
title="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.` }}
footer={(
<>
<Button variant="outline" disabled={deleting} onClick={closeDelete}>Cancel</Button>
<Button
variant="outline"
className={css.deleteAction!}
disabled={deleting}
onClick={confirmDelete}
>
Delete workspace
</Button>
</>
)}
>
{deleting && <div className={css.deleteStatus} role="status">Deleting workspace</div>}
{deleteError !== null && <div className={css.renameError} role="alert">{deleteError}</div>}
</Modal>
</div>
)
}
@@ -60,7 +60,7 @@ export function WorkspaceCreateFlow({
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = normalizedWorkspaceName !== ''
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
const items: MenuEntry[] = [
@@ -42,6 +42,8 @@ export type WorkspaceBrowserInjected = {
) => Promise<{ items: readonly SessionSearchResultItem[]; hasMore: boolean }>
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Reorder a session inside its Workspace account (DOM-insertBefore
* semantics: omitted anchor appends to the end). The view refreshes from
@@ -45,6 +45,7 @@ export function apply(ctx: ClientContext): void {
open: (sessionId) => { ctx.sessions.open(sessionId) },
searchSessions,
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
@@ -3,7 +3,7 @@
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename; the session hover card is suppressed while a menu
* is open.
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
*/
import { useState } from 'react'
import clsx from 'clsx'
@@ -39,12 +39,12 @@ const WORKSPACE_MENU_ITEMS = [
* @param props.onCreate - start a frontend Session inside this Workspace.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
export function ProjectRowItem({ group, onToggle, onCreate, actions }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
onRename?: (() => void) | undefined
/** Real-Workspace actions; absent for the ungrouped bucket (no menu shown). */
actions?: { rename: () => void; delete: () => void } | undefined
}) {
const row = group
const active = group.expanded && group.containsCurrent
@@ -68,15 +68,19 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
{onRename !== undefined && (
{actions !== undefined && (
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={WORKSPACE_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename()
// Delete is visual-only for now.
// 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. */
if (id !== 'rename' && id !== 'delete') return
if (id === 'rename') actions.rename()
else actions.delete()
}}
portal
closeOnPointerLeave