Pin Session hover-card directory through the browser, blank, and empty cases

This commit is contained in:
Turtle committed 2026-10-19 18:15:15 +08:00
1 parent 446e1ea400
commit 9984cc1627
7 files changed
+69 -34

No files matched your search

@@ -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
+1 -1
View File
@@ -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.
</details>
+1 -1
View File
@@ -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 会关闭卡片。
</details>
@@ -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 (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{displayTitle(node, t)}</div>
{node.cwd !== undefined && <div className={css.hoverPath}>{abbreviateHomePath(node.cwd, home)}</div>}
{cwd !== undefined && <div className={css.hoverPath}>{cwd}</div>}
{/* Same placeholder rule as the row's trailing cell: no timestamp
before the first prompt. */}
{!node.blank && <div className={css.hoverTime}>{hoverTimeLabel(node.updatedAt, now, t)}</div>}
@@ -698,7 +699,13 @@ export function SessionNodeItem({
return (
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} home={home} renderSlot={renderSlot} t={t} />}
content={<SessionHoverContent
node={node}
cwd={row.cwd === undefined ? undefined : abbreviateHomePath(row.cwd, home)}
now={now}
renderSlot={renderSlot}
t={t}
/>}
openDelayMs={800}
grouped
disabled={menuOpen || drag?.active === true}
@@ -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 }),
}
}
@@ -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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} home="/home/u" t={t} />)
hover()
expect(screen.getByText('~/project')).toBeTruthy()
view.unmount()
const { cwd: _, ...withoutCwd } = node
render(<SessionNodeItem node={withoutCwd} currentId={undefined} now={0} onOpen={vi.fn()} home="/home/u" t={t} />)
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(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} home="/home/u" t={t} />)
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', () => {
@@ -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([]),