diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index ce7fbb558f..4bb075eb7e 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -54,8 +54,8 @@ en: 83031e7505fc228a zh: 03e17c9428fe29fd /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards: - en: b15e684f12936689 - zh: df3ef53e56863bd9 + en: 8bcb7bd90d7c5773 + zh: 17663be926a099d2 /deepseek-ai-dsh-client-ui-workspace/further-exploration: en: 2abbf27abbd71ba4 zh: ace33fa2ea042357 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 3809d5a076..ecfc8f52b5 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -184,7 +184,7 @@ Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measu ### Hover cards -Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card shows its working directory below the title, abbreviating a POSIX home prefix as `~`, and omits that line when the Host reports no directory. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. A Session card, including a blank New Session card, shows its working directory below the title, abbreviating a POSIX home prefix as `~`, and omits that line when the Host reports no directory or an empty one. A Session card also renders its `sidebar.session.row.hover` seat between the relative time and the trailing status lines whenever the card is open, independent of the row's own state. The first Workspace or Session card in a physical list waits 800ms; after a card is displayed, moving to another Workspace or Session in the same list replaces it immediately. The list and its active portaled card form one pointer interaction: entering the card to read or copy preserves immediate handoff to a neighboring Workspace or Session. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle, with no separate group timer. Leaving a list with no active card resets the dwell immediately. Small action tooltips coexist with the right-side hover card; pin, archive, Workspace new-session, and search-result unarchive tooltips use portals to stay above it. Hovering actions, including More, preserves the card; opening the More menu or starting a Session drag dismisses it. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index e2df6e96dc..2b51da8011 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -184,7 +184,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序 ### 悬浮卡片 -Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。Session 卡片在标题下方显示其工作目录,POSIX 主目录前缀缩写为 `~`;Host 未报告目录时省略该行。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。 +Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。Session 卡片(包括空白「新会话」卡片)在标题下方显示其工作目录,POSIX 主目录前缀缩写为 `~`;Host 未报告目录或目录为空时省略该行。 Session 卡片在卡片打开期间还会在相对时间与行尾状态行之间渲染 `sidebar.session.row.hover` seat,与行自身状态无关。同一物理列表中的首张 Workspace 或 Session 卡片等待 800ms;实际显示一张后,移到该列表的其他 Workspace 或 Session 会立即替换卡片。列表与其当前活动的 portal 卡片构成一次指针交互:进入卡片阅读或复制后,移到相邻 Workspace 或 Session 仍会立即切换。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置,不另设分组计时器。列表没有活动卡片时,指针离开便立即重置等待。小型操作提示与右侧悬浮卡片同时显示;置顶、归档、Workspace 新建会话及搜索结果取消归档的提示通过 portal 保持在卡片上层。悬停操作按钮(包括「更多」)保留卡片;打开「更多」菜单或开始拖拽 Session 会关闭卡片。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index db278c2754..7005cb418c 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -424,10 +424,11 @@ function PinnedIndicator({ t }: { t: RowTranslate }) { * section, and every relevant live status. The task section sits above the * status lines so they stay the card's trailing status line. */ -function SessionHoverContent({ node, now, home, renderSlot, t }: { +function SessionHoverContent({ node, cwd, now, renderSlot, t }: { node: SessionNode + /** Display working directory; absent omits the line. */ + cwd: string | undefined now: number - home: string | undefined renderSlot: RowRenderSlots t: RowTranslate }) { @@ -438,7 +439,7 @@ function SessionHoverContent({ node, now, home, renderSlot, t }: { return (
{displayTitle(node, t)}
- {node.cwd !== undefined &&
{abbreviateHomePath(node.cwd, home)}
} + {cwd !== undefined &&
{cwd}
} {/* Same placeholder rule as the row's trailing cell: no timestamp before the first prompt. */} {!node.blank &&
{hoverTimeLabel(node.updatedAt, now, t)}
} @@ -698,7 +699,13 @@ export function SessionNodeItem({ return ( } + content={} openDelayMs={800} grouped disabled={menuOpen || drag?.active === true} diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 9596c49428..608c0b2a58 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -59,7 +59,7 @@ export interface SessionNode { /** In the registry-global archive set: shown grayed in place and not openable. */ archived: boolean updatedAt: number - /** Session working directory; absent when the Host reports none. */ + /** Session working directory; absent when the Host reports none or an empty path. */ cwd?: string } @@ -415,7 +415,7 @@ function sessionNode( pinned: !archived.has(s.id) && pinned.has(s.id), archived: archived.has(s.id), updatedAt: s.updatedAt, - ...(s.cwd === undefined ? {} : { cwd: s.cwd }), + ...(s.cwd === undefined || s.cwd === '' ? {} : { cwd: s.cwd }), ...(pendingInteraction === undefined ? {} : { pendingInteraction }), } } diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 8a392726c9..d7b1587b43 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { ComponentProps } from 'react' import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -685,30 +685,37 @@ describe('workspace browser rows', () => { } }) - it('Session hover card shows the home-abbreviated working directory only when the Host reports one', () => { - vi.useFakeTimers() - try { - const node: SessionNode = { - id: sid('s-cwd'), title: 'Located', blank: false, running: false, - runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false, - cwd: '/home/u/project', - } - const hover = (): void => { - fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(800) }) - } - const view = render() - hover() - expect(screen.getByText('~/project')).toBeTruthy() - view.unmount() - const { cwd: _, ...withoutCwd } = node - render() - hover() - expect(screen.getAllByText('Located').length).toBeGreaterThanOrEqual(2) - expect(screen.queryByText(/project/)).toBeNull() - } finally { - vi.useRealTimers() + describe('Session hover-card working directory', () => { + const located: SessionNode = { + id: sid('s-cwd'), title: 'Located', blank: false, running: false, + runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false, + cwd: '/home/u/project', } + function hoverCard(node: SessionNode): void { + render() + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(800) }) + } + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { vi.useRealTimers() }) + + it('shows a home-rooted directory as ~', () => { + hoverCard(located) + expect(screen.getByText('~/project')).toBeTruthy() + }) + + it('omits the line when the Host reports no directory', () => { + const { cwd: _cwd, ...withoutCwd } = located + hoverCard(withoutCwd) + expect(screen.getAllByText('Located')).toHaveLength(2) + expect(screen.queryByText(/project/)).toBeNull() + }) + + it('shows the directory on a blank New Session card', () => { + hoverCard({ ...located, blank: true }) + expect(screen.getAllByText('新会话')).toHaveLength(2) + expect(screen.getByText('~/project')).toBeTruthy() + }) }) it('session row menu opens without opening the session and Escape closes it', () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 4b62826cbc..f53d63b275 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -297,6 +297,27 @@ describe('WorkspaceBrowser', () => { } }) + it.each(['workspace', 'flat', 'ungrouped'] as const)('%s Session hover card shows the Session directory with home as ~', (mode) => { + vi.useFakeTimers() + try { + localStorage.clear() + const preferences = createWorkspaceViewStore().create() + preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace') + preferences.actions.setGroupExpanded(mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha', true) + localStorage.setItem('dsh.workspace.view.v5', JSON.stringify(preferences.getSnapshot())) + mount({ + useSessions: hook(sessionState([summary('located', 1, { cwd: '/home/u/Documents/project' })])), + useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ['located'])])), + useHostInfo: selector => selector({ home: '/home/u', isLoopback: true }), + }) + fireEvent.pointerEnter(screen.getByText('located').closest('[role="treeitem"]')?.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(800) }) + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => { const pending = { ...workspaceState([]),