Merge pull request #6840 from deepseek-harness/turtle/focus-click-rename-9a2d9fb21ffb

fix(web): ignore window-activating click in session title double-click rename
This commit is contained in:
Turtle authored and GitHub committed 2026-10-21 19:36:28 +08:00
commit a8e48df916
6 files changed
+73 -11

No files matched your search

@@ -27,8 +27,8 @@
en: 29facc4eae028dae
zh: 754ebac4cc294e65
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: e96b714aeb6dcd03
zh: 75b63aa2cafbb57a
en: 353ed9b66fab6486
zh: e38a4a37627a9426
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
+1 -1
View File
@@ -55,7 +55,7 @@ Collapsed search is one header action beside the view and add actions: activatin
The final built-in item in a Session row's "..." menu is Copy Session ID. It closes the menu and copies the complete ID directly, without opening a dialog. Clipboard success or failure appears in the global toast; a failed copy can be retried from the menu. Copying does not retain the Session, load its history, or call the Host.
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. When the click that activates an unfocused window is not delivered to the page, as on macOS, the next click does not open Rename. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived.
+1 -1
View File
@@ -55,7 +55,7 @@ kind: "package-reference"
Session 行“...”菜单的最后一个内置项为“复制 Session ID”。点击后关闭菜单并直接复制完整 ID,不打开对话框。复制成功或失败通过全局提示反馈;失败时可从菜单重试。复制不会 retain 会话、加载历史或调用 Host。
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。当激活未聚焦窗口的那次点击不会传递给页面时(如在 macOS 上),下一次点击不会打开 Rename。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。列表为空时显示居中的"图标在上、文字在下"占位;仅显示已归档视图用自己的文案(暂无已归档会话),并附"查看其他会话"文字按钮,点击把筛选切回隐藏已归档。
@@ -542,7 +542,8 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onRenameRequest - open the rename dialog from a title double-click (id + current title).
* @param props.onRenameRequest - open the rename dialog from a title double-click (id + current title);
* called only after a single press on the title since the window last lost focus.
* @param props.renderSlot - child-seat renderer for the row's action lists
* (`sidebar.workspaces.session.menu.item` / `sidebar.workspaces.session.row.action`),
* its leading and trailing decorations, and its hover-card section.
@@ -559,7 +560,7 @@ export function SessionNodeItem({
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
/** Open the rename dialog from a title double-click (id + current title). */
/** Open the rename dialog from a title double-click (id + current title); see the function JSDoc for the press precondition. */
onRenameRequest: (id: SessionNode['id'], currentTitle: string) => void
/** Scroll this row into view after search navigation, then acknowledge it. */
onReveal?: (() => void) | undefined
@@ -591,6 +592,11 @@ export function SessionNodeItem({
const menuOpenState = useMemo((): MenuOpenState => [menuOpen, setMenuOpen], [menuOpen])
const rowRef = useRef<HTMLDivElement>(null)
const titleRef = useRef<HTMLSpanElement>(null)
// On macOS the click that activates the window is not delivered, but the
// next click arrives with detail 2 and fires dblclick, so rename requires a
// detail-1 press on this title since the window last lost focus.
const renameArmed = useRef(false)
const disarmRename = useRef(() => { renameArmed.current = false }).current
const marquee = useTitleMarquee(titleRef)
useEffect(() => {
if (onReveal === undefined) return
@@ -659,9 +665,20 @@ export function SessionNodeItem({
<span
ref={titleRef}
className={css.title}
onMouseDown={(e) => {
if (e.detail !== 1) return
renameArmed.current = true
// One shared function and options: repeated presses add one listener.
window.addEventListener('blur', disarmRename, { once: true })
}}
onDoubleClick={row.blank
? undefined
: (e) => { e.stopPropagation(); onRenameRequest(node.id, row.title) }}
: (e) => {
e.stopPropagation()
if (!renameArmed.current) return
renameArmed.current = false
onRenameRequest(node.id, row.title)
}}
>
{title}
</span>
@@ -38,6 +38,16 @@ type SessionNodeItemProps = ComponentProps<typeof SessionNodeItemComponent>
const renderNoRowEntries: RowRenderSlot = () => null
// Direct row specs use the real required Slot share, empty unless the case supplies entries.
/**
* Browser double-click: the second press carries detail 2 and must not disarm.
* @param el - the double-clicked element.
*/
function realDoubleClick(el: Element): void {
fireEvent.mouseDown(el, { detail: 1 }); fireEvent.mouseUp(el, { detail: 1 }); fireEvent.click(el, { detail: 1 })
fireEvent.mouseDown(el, { detail: 2 }); fireEvent.mouseUp(el, { detail: 2 }); fireEvent.click(el, { detail: 2 })
fireEvent.doubleClick(el)
}
function SessionNodeItem({ renderSlot = renderNoRowEntries, onRenameRequest = () => {}, ...props }: Omit<
SessionNodeItemProps, 'renderSlot' | 'onRenameRequest'
> & Partial<Pick<SessionNodeItemProps, 'renderSlot' | 'onRenameRequest'>>) {
@@ -1231,14 +1241,45 @@ describe('workspace browser rows', () => {
}
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRenameRequest={onRenameRequest} t={t} />)
fireEvent.doubleClick(screen.getByText('Named'))
realDoubleClick(screen.getByText('Named'))
expect(onRenameRequest).toHaveBeenCalledWith(node.id, 'Named')
expect(onOpen).not.toHaveBeenCalled()
expect(onOpen).toHaveBeenCalledWith(node.id)
// A blank placeholder has no content to rename: double-click is inert.
view.rerender(<SessionNodeItem node={{ ...node, blank: true }} currentId={undefined} now={0}
onOpen={onOpen} onRenameRequest={onRenameRequest} t={t} />)
fireEvent.doubleClick(screen.getByText('新会话'))
realDoubleClick(screen.getByText('新会话'))
expect(onRenameRequest).toHaveBeenCalledOnce()
})
it('does not rename when the first click of the pair only activated the window', () => {
const onRenameRequest = vi.fn()
const onOpen = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'Named', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRenameRequest={onRenameRequest} t={t} />)
const title = screen.getByText('Named')
// macOS delivers only the second press of a window-activating pair.
fireEvent.mouseDown(title, { detail: 2 })
fireEvent.click(title, { detail: 2 })
fireEvent.doubleClick(title)
expect(onOpen).toHaveBeenCalledWith(node.id)
expect(onRenameRequest).not.toHaveBeenCalled()
// A press seen before the window lost focus does not arm the next pair.
fireEvent.mouseDown(title, { detail: 1 })
fireEvent.blur(window)
fireEvent.mouseDown(title, { detail: 2 })
fireEvent.doubleClick(title)
expect(onRenameRequest).not.toHaveBeenCalled()
// One armed press renames once.
realDoubleClick(title)
fireEvent.mouseDown(title, { detail: 2 })
fireEvent.doubleClick(title)
expect(onRenameRequest).toHaveBeenCalledOnce()
})
@@ -1409,7 +1450,7 @@ it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed history as %s', (
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
renderSlot={() => null} onRenameRequest={onRenameRequest} t={translate} />)
expect(screen.getByText(label)).toBeTruthy()
fireEvent.doubleClick(screen.getByText(label))
realDoubleClick(screen.getByText(label))
expect(onRenameRequest).toHaveBeenCalledWith(node.id, '')
})
@@ -739,11 +739,15 @@ describe('WorkspaceBrowser', () => {
open,
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 1 })
fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 2 })
fireEvent.doubleClick(screen.getByText('Alpha session'))
expect(requestSessionRename).toHaveBeenCalledWith(sid('alpha-s'), 'Alpha session')
expect(open).not.toHaveBeenCalled()
// The flat list threads the same request.
act(() => { b.store.actions.setGroupBy('flat') })
fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 1 })
fireEvent.mouseDown(screen.getByText('Alpha session'), { detail: 2 })
fireEvent.doubleClick(screen.getByText('Alpha session'))
expect(requestSessionRename).toHaveBeenCalledTimes(2)
})