feat(web): session-row Rename menu action with a browser-owned dialog
The row menu's Rename item opens the same dialog pattern as workspace rename (no client-side conflict rule; the host normalizes) and resolves through the session face via ctx.sessions.binding. Assembled-app snapshot covers the row-menu -> dialog -> unary-settle flow.
This commit is contained in:
@@ -92,12 +92,14 @@ type SessionTreeProps = Pick<
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned session rename dialog. */
|
||||
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, workspaces, query,
|
||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
@@ -192,6 +194,7 @@ function SessionTree({
|
||||
currentId={current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||
drag={dragProps}
|
||||
/>
|
||||
@@ -206,7 +209,7 @@ function SessionTree({
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
|
||||
function FlatList({ useSessions, open, onSessionRename, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'onSessionRename' | 'query'>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||
const now = Date.now()
|
||||
@@ -224,6 +227,7 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi
|
||||
currentId={list.current}
|
||||
now={now}
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||
onToggle={() => {}}
|
||||
flat
|
||||
@@ -249,6 +253,7 @@ export function WorkspaceBrowser({
|
||||
actions,
|
||||
startSession,
|
||||
open,
|
||||
renameSession,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
insertSessionBefore,
|
||||
@@ -309,6 +314,38 @@ export function WorkspaceBrowser({
|
||||
})
|
||||
}
|
||||
|
||||
// Session rename dialog (same browser-owned pattern as workspace rename;
|
||||
// sessions have no client-side name-conflict rule — the host normalizes).
|
||||
const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null)
|
||||
const [sessionRenameDraft, setSessionRenameDraft] = useState('')
|
||||
const [sessionRenaming, setSessionRenaming] = useState(false)
|
||||
const [sessionRenameError, setSessionRenameError] = useState<string | null>(null)
|
||||
const sessionRenameTrimmed = sessionRenameDraft.trim()
|
||||
const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === ''
|
||||
|| sessionRenameTarget === null || sessionRenameTrimmed === sessionRenameTarget.currentTitle
|
||||
const closeSessionRename = () => {
|
||||
if (sessionRenaming) return
|
||||
setSessionRenameTarget(null)
|
||||
setSessionRenameError(null)
|
||||
}
|
||||
const confirmSessionRename = () => {
|
||||
if (sessionRenameBlocked) return
|
||||
setSessionRenaming(true)
|
||||
setSessionRenameError(null)
|
||||
renameSession(sessionRenameTarget.sessionId, sessionRenameTrimmed).then(() => {
|
||||
setSessionRenaming(false)
|
||||
setSessionRenameTarget(null)
|
||||
}).catch((reason: unknown) => {
|
||||
setSessionRenaming(false)
|
||||
setSessionRenameError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
}
|
||||
const onSessionRename = (sessionId: SessionNode['id'], currentTitle: string) => {
|
||||
setSessionRenameTarget({ sessionId, currentTitle })
|
||||
setSessionRenameDraft(currentTitle)
|
||||
setSessionRenameError(null)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -424,10 +461,11 @@ export function WorkspaceBrowser({
|
||||
itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && (groupBy === 'flat'
|
||||
? <FlatList useSessions={useSessions} open={open} query={query} />
|
||||
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
workspaces={workspaces}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
@@ -479,6 +517,37 @@ export function WorkspaceBrowser({
|
||||
)}
|
||||
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={sessionRenameTarget !== null}
|
||||
onClose={closeSessionRename}
|
||||
title="Rename session"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" disabled={sessionRenaming} onClick={closeSessionRename}>Cancel</Button>
|
||||
<Button variant="primary" disabled={sessionRenameBlocked} onClick={confirmSessionRename}>Rename</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className={css.renameInput}
|
||||
value={sessionRenameDraft}
|
||||
aria-label="Session name"
|
||||
autoFocus
|
||||
disabled={sessionRenaming}
|
||||
onFocus={(e) => { e.target.select() }}
|
||||
onChange={(e) => { setSessionRenameDraft(e.target.value); setSessionRenameError(null) }}
|
||||
onCompositionStart={() => { composingRef.current = true }}
|
||||
onCompositionEnd={() => { composingRef.current = false }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !composingRef.current) {
|
||||
e.preventDefault()
|
||||
confirmSessionRename()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sessionRenameError !== null && <div className={css.renameError} role="alert">{sessionRenameError}</div>}
|
||||
</Modal>
|
||||
<Modal
|
||||
open={deleteTarget !== null}
|
||||
onClose={closeDelete}
|
||||
|
||||
@@ -93,6 +93,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
startSession: (workspaceId?: WorkspaceId) => void
|
||||
/** Open a real Session. */
|
||||
open: (sessionId: SessionId) => void
|
||||
/** Rename a Session (explicit user title; resolves on host acceptance). */
|
||||
renameSession: (sessionId: SessionId, title: string) => Promise<void>
|
||||
/** 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. */
|
||||
|
||||
@@ -51,6 +51,14 @@ export function apply(ctx: ClientContext): void {
|
||||
// the runtime's shared action (recent-Workspace projection inside).
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
||||
renameSession: async (sessionId, title) => {
|
||||
// Row → session-face hop: rename is a per-session verb (ISession), not
|
||||
// a list-service verb; the binding resolves any listed session.
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
|
||||
const result = await session.rename(title)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
},
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* 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. Workspace Rename/Delete are wired; session actions remain visual-only.
|
||||
* except workspace Rename/Delete and session Rename; the session hover card is
|
||||
* suppressed while a menu is open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -159,12 +159,14 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
|
||||
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onRename, onToggle, drag, flat = false }: {
|
||||
node: SessionNode
|
||||
depth: number
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
onOpen: (id: SessionNode['id']) => void
|
||||
/** Open the browser-owned session rename dialog (row menu action). */
|
||||
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
||||
onToggle: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group roots outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
@@ -232,7 +234,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={SESSION_MENU_ITEMS}
|
||||
onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title) // fork/delete stay visual-only.
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
anchor={(
|
||||
@@ -264,6 +269,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
||||
currentId={currentId}
|
||||
now={now}
|
||||
onOpen={onOpen}
|
||||
onRename={onRename}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -68,7 +68,8 @@ describe('workspace browser rows', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onToggle = vi.fn()
|
||||
const view = render(
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
|
||||
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle} />,
|
||||
)
|
||||
|
||||
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||
@@ -88,7 +89,8 @@ describe('workspace browser rows', () => {
|
||||
view.rerender(
|
||||
<SessionNodeItem
|
||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
|
||||
depth={1} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onToggle={onToggle}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||
@@ -135,19 +137,29 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and closes on selection', () => {
|
||||
it('session row menu opens without opening the session and dispatches rename', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onToggle={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
// Rename dispatches with the current display title (dialog prefill).
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Fork and Delete stay visual-only.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete session' }))
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
@@ -159,7 +171,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} flat />)
|
||||
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||
})
|
||||
|
||||
@@ -170,7 +183,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||
expanded: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -196,7 +210,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||
expanded: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
|
||||
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('Idle')).toBeTruthy()
|
||||
@@ -213,7 +228,8 @@ describe('workspace browser rows', () => {
|
||||
}
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -230,7 +246,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={active} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -243,7 +260,8 @@ describe('workspace browser rows', () => {
|
||||
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onToggle={vi.fn()} drag={after} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -55,6 +55,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
actions: store.actions,
|
||||
startSession: vi.fn(),
|
||||
open: vi.fn(),
|
||||
renameSession: vi.fn(async () => {}),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
|
||||
Reference in New Issue
Block a user