Jelajahi Sumber

feat(web): reveal a clipped sidebar session title on hover (#4423)

* feat(web): reveal a clipped sidebar session title on hover

A session title wider than its sidebar row is clipped with an ellipsis, so a
long parent title hides the `(1)` that distinguishes its fork. Hovering the row
now scrolls the title to its far edge and returns it to the start when the
pointer leaves. The title stays an `overflow: hidden` scrollport, so nothing
reflows and no scrollbar appears; `scroll-behavior: smooth` supplies the motion
(reduced motion keeps the jump), and the hovered row clips instead of
ellipsizing because a scrolled box still paints the ellipsis over the revealed
characters.

Covered by the row component spec, the stylesheet contract spec, and a new
keyless Chromium scenario that hovers a seeded clipped row in the assembled
application and asserts the reached scroll extent.

* fix(web): return the revealed session title in one step

Review follow-up on the hover reveal:

- Return the title to its start with `scrollTo({ behavior: 'instant' })` instead
  of a smooth scroll. A smooth return carried the resting ellipsis and the
  narrowing cell over the travelling text for its whole duration.
- Scope `scroll-behavior: smooth` to `.sessionRow .title` so Workspace rows are
  untouched, and fold the reduced-motion reset into the file's existing block.
- Guard the hover-only `text-overflow: clip` with `@media (hover: hover)`: a
  touch tap latches `:hover` after the title has already returned, which would
  otherwise strand that row without its ellipsis.
- Pin the contract in the browser scenario: computed `scroll-behavior` is
  `smooth` by default and `auto` under reduced motion, and the return lands
  inside one step. Add the unit case for the explicit instant return.
Turtle 2099 3 hari lalu
induk
melakukan
65648082a3

+ 6 - 0
.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md
+2026-09-17-web-sidebar-session-title-hover-scroll.md: 07f802f2c64aa40bb13ba679cda2007112b58d69
+2026-09-17-web-sidebar-session-title-hover-scroll.zh.md: aa08bac5018304a0e25e93062f7cc45ca415799e

+ 41 - 0
.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md

@@ -0,0 +1,41 @@
+# Agent Note: Web sidebar Session title hover scroll
+
+Status: implemented
+
+English | [中文](2026-09-17-web-sidebar-session-title-hover-scroll.zh.md)
+
+## Problem
+
+A Session title wider than its sidebar row is clipped with an ellipsis, so the part that distinguishes two related Sessions is the part that disappears. Fork makes it visible: the child's persisted title is the parent's plus an increment, so a long parent title leaves the `(1)` off the row's right edge. Opening the hover card shows the full title, but the card needs a dwell, covers neighboring rows, and is not the comparison the user is trying to make.
+
+## Decision
+
+A Session row reveals its clipped title in place while the pointer rests on the row. `SessionNodeItem` writes `title.scrollLeft = scrollWidth - clientWidth` on pointer enter and returns it with `scrollTo({ left: 0, behavior: 'instant' })` on pointer leave; the title element keeps `overflow: hidden`, so nothing reflows, no scrollbar appears, and the page never scrolls. Entering glides because the title carries `scroll-behavior: smooth` (the file's reduced-motion block restores `auto`), so the handler owns no timer or animation loop. Returning is deliberately one step: a smooth return would travel under the resting ellipsis and the row's narrowing cell for its whole duration.
+
+While the pointer is on the row, `text-overflow` switches from `ellipsis` to `clip`: a scrolled `overflow: hidden` box still paints the ellipsis at its right edge, which would cover exactly the characters the scroll just revealed (verified in Chromium before the rule was written). That rule sits behind `@media (hover: hover)`, because a touch tap latches `:hover` on the row after the title has already returned, and an unguarded rule would leave the tapped row hard-clipped at its start.
+
+The reveal belongs to the Session row, not to a single cell: entering anywhere on the row starts it, and it ends only when the pointer leaves the row. Workspace rows and search results keep their ellipsis behavior — their titles distinguish nothing by a trailing token — and only the Session title carries the scroll behavior.
+
+The hovered row is also the wider cell: its trailing relative-time label yields to the row menu, so the hovered `clientWidth` is larger than the resting one and the hovered scroll maximum is smaller. A handler that read the resting width would compute a larger delta that the browser clamps to the hovered maximum, reaching the same far edge; the browser scenario therefore polls the live extent instead of the value the handler read.
+
+## Alternatives considered
+
+**A moving inner span or a duplicated-copy marquee.** Rejected: `text-overflow: ellipsis` ellipsizes text directly inside the clipping block, so moving the text requires a nested element whose own clipping (or the duplicate copy) moves with it; the resting ellipsis, the existing DOM, and the accessible text would all change. Scrolling the existing element keeps every one of them.
+
+**A rAF or CSS-loop marquee.** Rejected: a continuous animation keeps moving while the user reads and needs per-row lifetime management. One scroll to the far edge and one back is the whole reveal.
+
+**`scrollTo({ behavior: 'smooth' })` for both moves.** Rejected: it needs a `matchMedia` probe for reduced motion plus a capability branch, and it would keep the return animating under the resting ellipsis. The stylesheet supplies the entering glide, and the leaving move asks for `instant` explicitly.
+
+**A handler-owned clip state cleared on `scrollend`.** Considered for the return transient; rejected because returning in one step removes the transient without a second state, an event listener, or a browser capability the jsdom lane lacks.
+
+**`direction: rtl` on hover.** Rejected: it jumps instead of scrolling and reorders punctuation inside mixed-direction titles.
+
+**Leaving the reveal to the hover card.** Rejected: the card opens only after its dwell and covers neighboring rows, so it cannot support comparing two adjacent rows.
+
+## Consequences
+
+A long title still occupies exactly one row line; the scroll range is measured from the element's own box, so a width change (the sidebar resizing, or the hovered row's own cell change) is picked up on the next pointer enter, and the instant return leaves the resting ellipsis and the text consistent whenever the pointer leaves. Revealing depends on a pointer resting on the row, so touch and keyboard users still rely on the hover card and the Rename dialog for the full title; removing the reveal leaves an ordinary ellipsized row.
+
+## Testing
+
+`packages/client/ui-workspace/tests/rows.client.spec.tsx` pins the scroll position written on pointer enter, the explicit instant return, and that a title that fits stays at its start. `packages/client/ui-workspace/tests/browser-styles.client.spec.ts` pins the stylesheet contract for the hovered row. `apps/web/tests/sidebar-title-hover-scroll.e2e.ts` hovers a seeded clipped row in the assembled application and asserts that the title really overflows, reaches its own live extent under the pointer, stops ellipsizing, returns to its start within one step, and that the computed scroll behavior is `smooth` in the default media state and `auto` under reduced motion.

+ 41 - 0
.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: Web 侧栏 Session 标题悬停滚动
+
+Status: implemented
+
+[English](2026-09-17-web-sidebar-session-title-hover-scroll.md) | 中文
+
+## 问题
+
+标题宽于侧栏行时会被省略号裁切,于是区分两个相关 Session 的那一部分恰好消失。Fork 会让这件事暴露出来:子会话持久化标题是父标题加一个递增序号,父标题一长,`(1)` 就落到行的右边界之外。悬停卡片能显示完整标题,但它需要停留等待、会盖住相邻行,而且用户想做的比较并不是看卡片。
+
+## 决策
+
+指针停在 Session 行上时,该行就地显示被裁切的标题。`SessionNodeItem` 在指针进入时写入 `title.scrollLeft = scrollWidth - clientWidth`,在指针离开时用 `scrollTo({ left: 0, behavior: 'instant' })` 一次性回到起点;标题元素保持 `overflow: hidden`,因此不会重排、不会出现滚动条,页面本身也不滚动。进入时之所以平滑,是因为标题带有 `scroll-behavior: smooth`(本文件的减少动效块恢复为 `auto`),处理函数因此不持有定时器或动画循环。返回被有意做成一步到位:平滑返回会让静止态省略号和收窄后的格子在整个返回过程中一直覆盖着文字。
+
+指针停留在行上期间,`text-overflow` 由 `ellipsis` 切换为 `clip`:已滚动的 `overflow: hidden` 盒子仍会在右边界绘制省略号,而那正是刚刚被滚动显示出来的字符(写这条规则前已在 Chromium 中验证)。这条规则位于 `@media (hover: hover)` 之内,因为触屏点按会在标题已经返回之后仍让该行的 `:hover` 保持钉住,不设保护就会让被点按的行停在起点且硬裁切、失去省略号。
+
+该行为属于 Session 行,而不是某一格:指针进入行的任意位置都会开始,只有离开该行才结束。Workspace 行与搜索结果保留省略号行为——它们的标题没有靠尾部记号区分的含义——只有 Session 标题带有滚动行为。
+
+悬停中的行同时也是更宽的格子:行尾的相对时间标签会让位给行操作菜单,因此悬停时的 `clientWidth` 比静止时更大,可滚动上限更小。若处理函数读到的是静止宽度,它会算出一个更大的位移,浏览器会把它夹到悬停状态的上限,最终同样到达远端;因此浏览器场景断言的是实时到达范围,而不是处理函数读到的数值。
+
+## 备选方案
+
+**移动的内层元素或复制副本的跑马灯。** 否决:`text-overflow: ellipsis` 只对裁切块内直接包含的文本生效,因此移动文本需要嵌套元素,而它自身的裁切(或复制副本)会随之移动;静止态的省略号、现有 DOM 和可访问文本都会改变。滚动现有元素则三者都保留。
+
+**rAF 或 CSS 循环跑马灯。** 否决:持续动画会在用户阅读时继续移动,并且需要逐行管理生命周期。滚到远端再滚回一次,就是这个揭示所需的全部动作。
+
+**两个方向都用 `scrollTo({ behavior: 'smooth' })`。** 否决:它需要一个 `matchMedia` 探测来处理减少动效,还需要一个能力分支,并且会让返回过程在静止态省略号之下持续动画。进入的平滑由样式表提供,离开则显式要求 `instant`。
+
+**由处理函数持有裁切状态并在 `scrollend` 时清除。** 曾用于消除返回过程的瞬态;否决,因为一步返回直接消除该瞬态,无需第二个状态、事件监听或 jsdom 车道缺少的浏览器能力。
+
+**悬停时使用 `direction: rtl`。** 否决:它是跳变而非滚动,并且会重排混合方向标题中的标点。
+
+**把揭示交给悬停卡片。** 否决:卡片要等停留时间结束才出现,而且会盖住相邻行,无法支持并排比较两行。
+
+## 后果
+
+长标题仍然只占一行;滚动范围按元素自身盒宽测量,因此宽度变化(侧栏缩放,或悬停行自身的格子变化)会在下一次指针进入时被重新读取,而一步返回保证指针离开时静止态省略号与文字始终一致。揭示依赖指针停在行上,所以触屏与键盘用户仍需通过悬停卡片和重命名对话框获取完整标题;去掉该行为后,这一行只是普通的省略号行。
+
+## 测试
+
+`packages/client/ui-workspace/tests/rows.client.spec.tsx` 钉住指针进入时写入的滚动位置、显式的一步返回,以及能放下的标题停留在起点。`packages/client/ui-workspace/tests/browser-styles.client.spec.ts` 钉住悬停行的样式表契约。`apps/web/tests/sidebar-title-hover-scroll.e2e.ts` 在组装后的应用中悬停一条被裁切的种子会话行,断言标题确实溢出、到达指针下的实时范围、停止省略、一步回到起点,并断言默认媒体状态下计算出的滚动行为为 `smooth`、减少动效下为 `auto`。

+ 86 - 0
apps/web/tests/sidebar-title-hover-scroll.e2e.ts

@@ -0,0 +1,86 @@
+// Web e2e scenario: hovering a sidebar session row reveals a title wider than
+// its cell. The jsdom lane pins only the scroll position the handler writes;
+// the assembled browser is where the title really clips, really reaches its own
+// hovered extent (the trailing relative-time cell yields to the row menu while
+// the pointer rests on the row, so the hovered cell is wider than the resting
+// one), and really returns to its start when the pointer leaves.
+//
+// Zero model calls: seeding one renamed session and hovering its row touches no
+// provider.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import type { Browser, Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
+/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */
+const TITLE = 'Forked session clipped before its suffix (1)'
+
+describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
+    const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-title-hover-scroll')
+    await workspace.attachSession(id)
+    await scaffold.ctx.sessionController.rename({ sessionId: id, title: TITLE })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('reveals the clipped title under the pointer and restores its start after', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll'))
+    const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) })
+    await row.waitFor({ timeout: 20_000 })
+    const title = row.getByText(TITLE, { exact: true })
+
+    // At rest the row clips its title with an ellipsis and holds its start.
+    expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0)
+    expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
+    expect(await title.evaluate(el => el.scrollLeft)).toBe(0)
+    expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth')
+
+    await row.hover()
+    // Poll against the live extent: the reveal must reach the far edge of the
+    // hovered cell (the wider one — the relative-time label yields to the row
+    // menu), whatever width the handler itself read.
+    await expect.poll(
+      async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1),
+      { timeout: 10_000 },
+    ).toBe(true)
+    // Scrolled to its end, the title must not paint the ellipsis over the
+    // characters the reveal reached.
+    expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip')
+
+    // Leaving returns in one step: a smooth return would still be travelling
+    // inside this window, and it would carry the resting ellipsis back with it.
+    await page.mouse.move(0, 0)
+    await expect.poll(
+      async () => title.evaluate(el => el.scrollLeft),
+      { timeout: 150, interval: 20 },
+    ).toBe(0)
+    expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis')
+
+    // The reduce override reaches the real stylesheet, so the reveal jumps
+    // instead of gliding.
+    await page.emulateMedia({ reducedMotion: 'reduce' })
+    expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto')
+    await page.emulateMedia({ reducedMotion: null })
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -75,6 +75,7 @@
     "tests/seeded-history.e2e.ts",
     "tests/stats-paged-history.e2e.ts",
     "tests/sidebar-scrollbar.e2e.ts",
+    "tests/sidebar-title-hover-scroll.e2e.ts",
     "tests/rail-search-expand.e2e.ts",
     "tests/conversation-column-overflow.e2e.ts",
     "tests/ptc-round.e2e.ts",

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: d57b7af4767a5a0206aec4ee9d9ed4fbbe37a21c
-README.zh.md: 465150610de2f4f6e89ae3f414b277a8d2f3b10b
+README.md: 3fbbf52fd5fabca3416b7fe566f579f4a74dd3f2
+README.zh.md: 04b0565f8fc17f183fd9c1b1b48a7edbd1ba32ba

+ 2 - 0
packages/client/ui-workspace/README.md

@@ -45,6 +45,8 @@ Collapsed search is one header action beside the view and add actions: activatin
 
 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. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Archive commits without a confirmation dialog and the row disappears from every grouping surface when the archive-set echo lands. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped.
 
+A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
+
 ### Pending interactions
 
 Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator.

+ 2 - 0
packages/client/ui-workspace/README.zh.md

@@ -45,6 +45,8 @@ kind: "package-reference"
 
 Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Archive 不经确认对话框直接提交,归档集合回声落地后,该行从所有分组视图中消失。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。
 
+标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
+
 ### 待处理交互
 
 Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器。

+ 20 - 0
packages/client/ui-workspace/src/client/rows/Rows.module.css

@@ -189,8 +189,23 @@
   outline: none;
 }
 
+/* Hovering a session row reveals a title wider than its cell: Rows.tsx scrolls
+   it to its end while the pointer rests on the row — smoothly, through the
+   scroll-behavior here — and returns it to the start in one step when the
+   pointer leaves. The unclipped state drops the ellipsis, which would otherwise
+   cover the characters the reveal reached. */
 .sessionRow .title {
   flex: 1;
+  scroll-behavior: smooth;
+}
+
+/* Guarded because a touch tap latches `:hover` on the row after the title has
+   already returned: an unguarded rule would strand that row without its
+   ellipsis. */
+@media (hover: hover) {
+  .sessionRow:hover .title {
+    text-overflow: clip;
+  }
 }
 
 .meta {
@@ -366,4 +381,9 @@
     animation: none;
     transition: none;
   }
+
+  /* The title reveal jumps to the far edge instead of gliding there. */
+  .sessionRow .title {
+    scroll-behavior: auto;
+  }
 }

+ 32 - 4
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -1,9 +1,10 @@
 /**
  * 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/Delete and session Rename/Fork/Archive; the session
- * and workspace hover cards are suppressed while a menu is open.
+ * time->ellipsis, action buttons) are CSS-only, and a session row's clipped
+ * title is scrolled programmatically while the row is hovered. Row ... menus are
+ * visual-only except workspace Rename/Delete and session Rename/Fork/Archive; the
+ * session and workspace hover cards are suppressed while a menu is open.
  */
 import { useEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
@@ -27,6 +28,30 @@ function displayTitle(node: SessionNode, t: RowTranslate): string {
   return node.blank ? t('session.new') : node.title
 }
 
+/**
+ * Reveal a title wider than its one-line cell while its row is hovered: the
+ * title clips its own text, so the far edge (a fork's incremented title, for
+ * example) is reachable by scrolling the element to its end. Leaving returns it
+ * to the start in one step, because the resting ellipsis and the narrowed cell
+ * would otherwise meet the text while it travelled back. A title that fits has
+ * no scroll range to move, and the stylesheet decides whether either move
+ * glides or jumps.
+ * @param title - the row's clipping title element.
+ * @param revealed - whether the pointer is on the row.
+ */
+function revealClippedTitle(title: HTMLSpanElement | null, revealed: boolean): void {
+  /* v8 ignore next -- defensive: the title span renders unconditionally. */
+  if (title === null) return
+  if (revealed) {
+    title.scrollLeft = title.scrollWidth - title.clientWidth
+    return
+  }
+  // jsdom implements no scrollTo; the lane's direct assignment is instant there
+  // anyway, so both paths land on the same resting position.
+  if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' })
+  else title.scrollLeft = 0
+}
+
 /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */
 function timeLabel(updatedAt: number, now: number, t: RowTranslate): string {
   const { unit, n } = relativeTime(updatedAt, now)
@@ -408,6 +433,7 @@ export function SessionNodeItem({
   const draggable = drag !== undefined && !row.blank
   const [menuOpen, setMenuOpen] = useState(false)
   const rowRef = useRef<HTMLDivElement>(null)
+  const titleRef = useRef<HTMLSpanElement>(null)
   useEffect(() => {
     if (onReveal === undefined) return
     rowRef.current?.scrollIntoView({ block: 'nearest' })
@@ -434,6 +460,8 @@ export function SessionNodeItem({
       role="treeitem"
       aria-selected={selected}
       onClick={() => { onOpen(node.id) }}
+      onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }}
+      onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }}
       draggable={draggable}
       onDragStart={drag === undefined || row.blank
         ? undefined
@@ -467,7 +495,7 @@ export function SessionNodeItem({
           {showStatus && <SessionStatusDots statuses={statuses} />}
         </span>
       )}
-      <span className={css.title}>{title}</span>
+      <span ref={titleRef} className={css.title}>{title}</span>
       {row.hasActiveSchedule && <ActiveScheduleIndicator t={t} />}
       {/* A blank New Session row is a provisional placeholder: nothing has
           happened in it yet, so a "now" timestamp and the row verbs

+ 9 - 0
packages/client/ui-workspace/tests/browser-styles.client.spec.ts

@@ -108,6 +108,15 @@ describe('WorkspaceBrowser.module.css list', () => {
       .toBe('var(--dsw-alias-interactive-bg-hover)')
   })
 
+  it('reveals a clipped session title by scrolling it on row hover', () => {
+    // Smooth versus reduced motion is pinned as a computed style in
+    // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges
+    // same-selector rules across media queries, so the reduce override would
+    // mask the smooth declaration here.
+    expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1')
+    expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip')
+  })
+
   it('pins both rail controls to the shared left anchor during the column slide', () => {
     expect(declarations('.rail .sectionHeader')?.get('justify-content')).toBe('flex-start')
     expect(declarations('.rail .iconButton')?.get('width')).toBe('36px')

+ 50 - 0
packages/client/ui-workspace/tests/rows.client.spec.tsx

@@ -169,6 +169,56 @@ describe('workspace browser rows', () => {
     expect(onOpen).toHaveBeenCalledWith(node.id)
   })
 
+  it('reveals a clipped session title by scrolling it while the row is hovered', () => {
+    const node: SessionNode = {
+      id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false,
+      running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
+    }
+    render(
+      <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
+    )
+    const row = screen.getByRole('treeitem')
+    const title = screen.getByText(node.title)
+    // jsdom lays out nothing: the title's clipped geometry is stated outright.
+    const geometry = (scrollWidth: number, clientWidth: number): void => {
+      Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true })
+      Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true })
+    }
+
+    geometry(320, 180)
+    fireEvent.pointerEnter(row)
+    expect(title.scrollLeft).toBe(140)
+    fireEvent.pointerLeave(row)
+    expect(title.scrollLeft).toBe(0)
+
+    // A title that fits has no scroll range: hovering leaves it at its start.
+    geometry(180, 180)
+    fireEvent.pointerEnter(row)
+    expect(title.scrollLeft).toBe(0)
+  })
+
+  it('returns a revealed title to its start in one step', () => {
+    const node: SessionNode = {
+      id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false,
+      running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0,
+    }
+    render(
+      <SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
+        onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
+    )
+    const row = screen.getByRole('treeitem')
+    const title = screen.getByText(node.title)
+    const scrollTo = vi.fn()
+    Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true })
+
+    fireEvent.pointerEnter(row)
+    fireEvent.pointerLeave(row)
+    // Browsers receive the explicit instant behavior that the stylesheet's
+    // smooth scroll-behavior would otherwise override.
+    expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' })
+  })
+
   it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => {
     const onOpen = vi.fn()
     const node: SessionNode = {

+ 1 - 0
tsconfig.host.json

@@ -67,6 +67,7 @@
     "apps/web/tests/cold-blank-session.e2e.ts",
     "apps/web/tests/stats-paged-history.e2e.ts",
     "apps/web/tests/sidebar-scrollbar.e2e.ts",
+    "apps/web/tests/sidebar-title-hover-scroll.e2e.ts",
     "apps/web/tests/rail-search-expand.e2e.ts",
     "apps/web/tests/conversation-column-overflow.e2e.ts",
     "apps/web/tests/ptc-round.e2e.ts",