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:
130
apps/web/tests/session-actions.snapshot.ts
Normal file
130
apps/web/tests/session-actions.snapshot.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
// Session row actions in the assembled fixture app: Rename opens the
|
||||||
|
// browser-owned dialog and settles the title from the unary response.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
|
||||||
|
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
|
||||||
|
|
||||||
|
const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||||
|
{ id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-slash', dir: 'ui-slash', url: '/plugins/ui-slash.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-command', dir: 'ui-command', url: '/plugins/ui-command.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-slash', '@deepseek-ai/dsh-client-ui-conversation'] },
|
||||||
|
{ id: '@deepseek-ai/dsh-client-ui-workspace', dir: 'ui-workspace', url: '/plugins/ui-workspace.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-conversation', '@deepseek-ai/dsh-client-ui-sidebar'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const bundles = new Map(PLUGINS.map(plugin => [
|
||||||
|
plugin.url,
|
||||||
|
readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
|
||||||
|
]))
|
||||||
|
|
||||||
|
interface FixtureWindow extends Window {
|
||||||
|
__DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
|
||||||
|
__ModuleLoader__?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResizeObserverStub {
|
||||||
|
observe(): void {}
|
||||||
|
disconnect(): void {}
|
||||||
|
unobserve(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as FixtureWindow
|
||||||
|
let unmount: (() => void) | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
history.replaceState(null, '', '/?fixture')
|
||||||
|
document.title = 'DeepSeek Harness'
|
||||||
|
const root = document.createElement('div')
|
||||||
|
root.id = 'root'
|
||||||
|
document.body.appendChild(root)
|
||||||
|
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||||
|
setTimeout(() => { callback(0) }, 0) as unknown as number)
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
|
||||||
|
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => { unmount?.() })
|
||||||
|
unmount = undefined
|
||||||
|
cleanup()
|
||||||
|
delete win.__DSH_BOOT__
|
||||||
|
delete win.__ModuleLoader__
|
||||||
|
delete (globalThis as Record<string, unknown>).__fxTiming
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
|
||||||
|
document.title = ''
|
||||||
|
history.replaceState(null, '', '/')
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function bootApp(): Promise<void> {
|
||||||
|
const root = document.querySelector<HTMLElement>('#root')
|
||||||
|
if (root === null) throw new Error('snapshot root missing')
|
||||||
|
act(() => {
|
||||||
|
const entry = new AppWebEntry(root, {
|
||||||
|
fetchBundle: (url) => {
|
||||||
|
const code = bundles.get(url)
|
||||||
|
return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
|
||||||
|
},
|
||||||
|
executeBundle: (code) => { (0, eval)(code) },
|
||||||
|
})
|
||||||
|
void entry.run()
|
||||||
|
unmount = () => { entry.dispose() }
|
||||||
|
})
|
||||||
|
await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session row element carrying the given visible label. */
|
||||||
|
function rowOf(label: string): HTMLElement {
|
||||||
|
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||||
|
const row = within(tree).getByText(label).closest<HTMLElement>('[role="treeitem"]')
|
||||||
|
if (row === null) throw new Error(`session row "${label}" missing`)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the row's ... menu and click one action. The anchor button is
|
||||||
|
* CSS-hover-revealed (real stylesheets are injected in this assembled run,
|
||||||
|
* so role queries filter it as hidden); target it directly. */
|
||||||
|
function pickRowAction(label: string, action: string): void {
|
||||||
|
const anchor = rowOf(label).querySelector<HTMLElement>(`button[aria-label="Session actions for ${label}"]`)
|
||||||
|
if (anchor === null) throw new Error(`row menu anchor for "${label}" missing`)
|
||||||
|
fireEvent.click(anchor)
|
||||||
|
fireEvent.click(screen.getByRole('menuitem', { name: action, hidden: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renames a session through the row-menu dialog; the row settles from the unary response', async () => {
|
||||||
|
await bootApp()
|
||||||
|
const sourceLabel = 'Fixture 历史会话'
|
||||||
|
await screen.findByText(sourceLabel)
|
||||||
|
|
||||||
|
pickRowAction(sourceLabel, 'Rename')
|
||||||
|
const input = await screen.findByLabelText('Session name')
|
||||||
|
expect((input as HTMLInputElement).value).toBe(sourceLabel)
|
||||||
|
fireEvent.change(input, { target: { value: ' 分叉 实验记录 ' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
|
||||||
|
|
||||||
|
// Host-side normalization collapses whitespace; the dialog closes on
|
||||||
|
// acceptance and the row re-labels without any push-frame wait.
|
||||||
|
const renamed = '分叉 实验记录'
|
||||||
|
await waitFor(() => { expect(screen.queryByLabelText('Session name')).toBeNull() })
|
||||||
|
await screen.findByText(renamed)
|
||||||
|
const tree = screen.getByRole('tree', { name: 'Sessions' })
|
||||||
|
expect(within(tree).queryByText(sourceLabel)).toBeNull()
|
||||||
|
|
||||||
|
const rows = [...tree.querySelectorAll('[role="treeitem"]')].map(row => ({
|
||||||
|
label: row.textContent?.replace(/\s+/g, ' ').trim() ?? '',
|
||||||
|
}))
|
||||||
|
await expect(`${JSON.stringify(rows, null, 2)}\n`)
|
||||||
|
.toMatchFileSnapshot('./snapshots/session-actions/rename-rows.json')
|
||||||
|
})
|
||||||
14
apps/web/tests/snapshots/session-actions/rename-rows.json
Normal file
14
apps/web/tests/snapshots/session-actions/rename-rows.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "fixture4 sessions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "New Sessionnow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "分叉 实验记录now"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "fixture2min"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -92,12 +92,14 @@ type SessionTreeProps = Pick<
|
|||||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
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. */
|
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||||
function SessionTree({
|
function SessionTree({
|
||||||
useSessions, startSession, open, workspaces, query,
|
useSessions, startSession, open, workspaces, query,
|
||||||
onRenameRequest, onDeleteRequest, insertSessionBefore,
|
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore,
|
||||||
}: SessionTreeProps) {
|
}: SessionTreeProps) {
|
||||||
const list = useSessions(s => s)
|
const list = useSessions(s => s)
|
||||||
const current = list.current
|
const current = list.current
|
||||||
@@ -192,6 +194,7 @@ function SessionTree({
|
|||||||
currentId={current}
|
currentId={current}
|
||||||
now={now}
|
now={now}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
|
onRename={onSessionRename}
|
||||||
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
onToggle={(id) => { setExpandedSessions(l => toggled(l, id)) }}
|
||||||
drag={dragProps}
|
drag={dragProps}
|
||||||
/>
|
/>
|
||||||
@@ -206,7 +209,7 @@ function SessionTree({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
/** 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 list = useSessions(s => s)
|
||||||
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -224,6 +227,7 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi
|
|||||||
currentId={list.current}
|
currentId={list.current}
|
||||||
now={now}
|
now={now}
|
||||||
onOpen={open}
|
onOpen={open}
|
||||||
|
onRename={onSessionRename}
|
||||||
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
|
||||||
onToggle={() => {}}
|
onToggle={() => {}}
|
||||||
flat
|
flat
|
||||||
@@ -249,6 +253,7 @@ export function WorkspaceBrowser({
|
|||||||
actions,
|
actions,
|
||||||
startSession,
|
startSession,
|
||||||
open,
|
open,
|
||||||
|
renameSession,
|
||||||
renameWorkspace,
|
renameWorkspace,
|
||||||
deleteWorkspace,
|
deleteWorkspace,
|
||||||
insertSessionBefore,
|
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
|
// Delete dialog is separate from the row so a successful removal can
|
||||||
// unmount that row without tearing down the in-flight confirmation state.
|
// unmount that row without tearing down the in-flight confirmation state.
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
|
||||||
@@ -424,10 +461,11 @@ export function WorkspaceBrowser({
|
|||||||
itself is wide-only. */}
|
itself is wide-only. */}
|
||||||
<div className={css.listArea}>
|
<div className={css.listArea}>
|
||||||
{wide && (groupBy === 'flat'
|
{wide && (groupBy === 'flat'
|
||||||
? <FlatList useSessions={useSessions} open={open} query={query} />
|
? <FlatList useSessions={useSessions} open={open} onSessionRename={onSessionRename} query={query} />
|
||||||
: (
|
: (
|
||||||
<SessionTree
|
<SessionTree
|
||||||
useSessions={useSessions}
|
useSessions={useSessions}
|
||||||
|
onSessionRename={onSessionRename}
|
||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
startSession={startSession}
|
startSession={startSession}
|
||||||
open={open}
|
open={open}
|
||||||
@@ -479,6 +517,37 @@ export function WorkspaceBrowser({
|
|||||||
)}
|
)}
|
||||||
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
|
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
|
||||||
</Modal>
|
</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
|
<Modal
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
onClose={closeDelete}
|
onClose={closeDelete}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
|||||||
startSession: (workspaceId?: WorkspaceId) => void
|
startSession: (workspaceId?: WorkspaceId) => void
|
||||||
/** Open a real Session. */
|
/** Open a real Session. */
|
||||||
open: (sessionId: SessionId) => void
|
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). */
|
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
|
||||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
/** 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).
|
// the runtime's shared action (recent-Workspace projection inside).
|
||||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||||
open: (sessionId) => { ctx.sessions.open(sessionId) },
|
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) },
|
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
|
||||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||||
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
|
||||||
* except workspace Rename; the session hover card is suppressed while a menu
|
* except workspace Rename/Delete and session Rename; the session hover card is
|
||||||
* is open. Workspace Rename/Delete are wired; session actions remain visual-only.
|
* suppressed while a menu is open.
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import clsx from 'clsx'
|
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'
|
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
|
node: SessionNode
|
||||||
depth: number
|
depth: number
|
||||||
currentId: string | undefined
|
currentId: string | undefined
|
||||||
now: number
|
now: number
|
||||||
onOpen: (id: SessionNode['id']) => void
|
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
|
onToggle: (id: SessionNode['id']) => void
|
||||||
/** Present only on draggable rows (workspace-group roots outside search). */
|
/** Present only on draggable rows (workspace-group roots outside search). */
|
||||||
drag?: RowDragProps | undefined
|
drag?: RowDragProps | undefined
|
||||||
@@ -232,7 +234,10 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
|||||||
open={menuOpen}
|
open={menuOpen}
|
||||||
onClose={() => { setMenuOpen(false) }}
|
onClose={() => { setMenuOpen(false) }}
|
||||||
items={SESSION_MENU_ITEMS}
|
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
|
portal
|
||||||
closeOnPointerLeave
|
closeOnPointerLeave
|
||||||
anchor={(
|
anchor={(
|
||||||
@@ -264,6 +269,7 @@ export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle,
|
|||||||
currentId={currentId}
|
currentId={currentId}
|
||||||
now={now}
|
now={now}
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
|
onRename={onRename}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ describe('workspace browser rows', () => {
|
|||||||
const onOpen = vi.fn()
|
const onOpen = vi.fn()
|
||||||
const onToggle = vi.fn()
|
const onToggle = vi.fn()
|
||||||
const view = render(
|
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"]')!
|
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
|
||||||
@@ -88,7 +89,8 @@ describe('workspace browser rows', () => {
|
|||||||
view.rerender(
|
view.rerender(
|
||||||
<SessionNodeItem
|
<SessionNodeItem
|
||||||
node={{ ...parent, children: [], expanded: false, running: false }}
|
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()
|
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
|
||||||
@@ -135,19 +137,29 @@ describe('workspace browser rows', () => {
|
|||||||
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
|
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 onOpen = vi.fn()
|
||||||
|
const onRename = vi.fn()
|
||||||
const node: SessionNode = {
|
const node: SessionNode = {
|
||||||
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
id: sid('s1'), title: 'One', children: [], hasChildren: false,
|
||||||
expanded: false, running: false, updatedAt: 0,
|
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' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||||
expect(onOpen).not.toHaveBeenCalled()
|
expect(onOpen).not.toHaveBeenCalled()
|
||||||
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
|
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(screen.queryByRole('menu')).toBeNull()
|
||||||
|
expect(onRename).toHaveBeenCalledWith(node.id, 'One')
|
||||||
expect(onOpen).not.toHaveBeenCalled()
|
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).
|
// Escape closes without selecting (Menu onClose path).
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
|
||||||
fireEvent.keyDown(document, { key: 'Escape' })
|
fireEvent.keyDown(document, { key: 'Escape' })
|
||||||
@@ -159,7 +171,8 @@ describe('workspace browser rows', () => {
|
|||||||
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
|
||||||
expanded: false, running: false, updatedAt: 0,
|
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()
|
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,7 +183,8 @@ describe('workspace browser rows', () => {
|
|||||||
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
|
||||||
expanded: false, running: true, updatedAt: 0,
|
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
|
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||||
fireEvent.pointerEnter(wrapper)
|
fireEvent.pointerEnter(wrapper)
|
||||||
act(() => { vi.advanceTimersByTime(500) })
|
act(() => { vi.advanceTimersByTime(500) })
|
||||||
@@ -196,7 +210,8 @@ describe('workspace browser rows', () => {
|
|||||||
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
|
||||||
expanded: false, running: false, updatedAt: 0,
|
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)
|
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||||
act(() => { vi.advanceTimersByTime(500) })
|
act(() => { vi.advanceTimersByTime(500) })
|
||||||
expect(screen.getByText('Idle')).toBeTruthy()
|
expect(screen.getByText('Idle')).toBeTruthy()
|
||||||
@@ -213,7 +228,8 @@ describe('workspace browser rows', () => {
|
|||||||
}
|
}
|
||||||
const inactive = dragProps()
|
const inactive = dragProps()
|
||||||
const { rerender } = render(
|
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')
|
const row = screen.getByRole('treeitem')
|
||||||
stubRect(row)
|
stubRect(row)
|
||||||
@@ -230,7 +246,8 @@ describe('workspace browser rows', () => {
|
|||||||
|
|
||||||
const active = dragProps({ active: true, marker: 'before' })
|
const active = dragProps({ active: true, marker: 'before' })
|
||||||
rerender(
|
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'))
|
stubRect(screen.getByRole('treeitem'))
|
||||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
// 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' })
|
const after = dragProps({ active: true, marker: 'after' })
|
||||||
rerender(
|
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/)
|
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
|||||||
actions: store.actions,
|
actions: store.actions,
|
||||||
startSession: vi.fn(),
|
startSession: vi.fn(),
|
||||||
open: vi.fn(),
|
open: vi.fn(),
|
||||||
|
renameSession: vi.fn(async () => {}),
|
||||||
renameWorkspace: vi.fn(async () => {}),
|
renameWorkspace: vi.fn(async () => {}),
|
||||||
deleteWorkspace: vi.fn(async () => {}),
|
deleteWorkspace: vi.fn(async () => {}),
|
||||||
insertSessionBefore: vi.fn(async () => {}),
|
insertSessionBefore: vi.fn(async () => {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user