diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index a13e14064a..d670fca862 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -2220,21 +2220,21 @@ export function Sidebar({ {isRemoteSession(session) && {t("remote:remote")}} {taskTitle(session.title)} - {lastReplyDate ? ( - <> - - {lastReplyDate} - - ) : null} {options?.global ? ( {owningProject} ) : null} -
+
+ {lastReplyDate ? ( + {lastReplyDate} + ) : null} setExpanded((value) => !value)} > - {expanded ? : } + {expanded ? : }
diff --git a/apps/desktop/src/lib/sidebar-session-date.ts b/apps/desktop/src/lib/sidebar-session-date.ts index 97a2093f45..f8c44747c8 100644 --- a/apps/desktop/src/lib/sidebar-session-date.ts +++ b/apps/desktop/src/lib/sidebar-session-date.ts @@ -24,9 +24,13 @@ export function formatSessionLastReplyDate( } return new Intl.DateTimeFormat(locale || undefined, { - dateStyle: "medium", + month: "short", + day: "numeric", }).format(date); } catch { - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date); + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + }).format(date); } } diff --git a/apps/desktop/src/styles/composer.css b/apps/desktop/src/styles/composer.css index 61d1054708..a3742d2257 100644 --- a/apps/desktop/src/styles/composer.css +++ b/apps/desktop/src/styles/composer.css @@ -737,19 +737,19 @@ display: flex; width: 100%; min-width: 0; - min-height: 38px; + min-height: 44px; align-items: center; gap: 10px; - padding: 5px 8px 5px 12px; + padding: 4px 8px 4px 12px; color: var(--ds-text-primary); text-align: left; } -.todo-dock-toggle { - width: 28px; - min-width: 28px; - height: 28px; - flex: 0 0 28px; +.todo-dock-toggle.btn { + width: 36px; + min-width: 36px; + height: 36px; + flex: 0 0 36px; padding: 0; color: var(--ds-text-muted); } diff --git a/apps/desktop/src/styles/sessions.css b/apps/desktop/src/styles/sessions.css index 13268eda95..ddadd5aa9a 100644 --- a/apps/desktop/src/styles/sessions.css +++ b/apps/desktop/src/styles/sessions.css @@ -406,7 +406,6 @@ color: var(--ds-accent); } -.sidebar-row-actions, .sidebar-menu-wrap { position: relative; display: inline-flex; @@ -414,6 +413,11 @@ flex: 0 0 auto; } +.thread-item-meta { + position: relative; + flex: 0 0 auto; +} + .thread-item-more { display: inline-flex; width: 24px; @@ -432,6 +436,7 @@ } .thread-item:hover .thread-item-more, +.thread-item:focus-within .thread-item-more, .sidebar-session-group-header:hover .thread-item-more, .sidebar-session-group-header:focus-within .thread-item-more, .thread-item.active .thread-item-more:hover, @@ -440,7 +445,6 @@ pointer-events: auto; } -.thread-item:focus-within .thread-item-more, .thread-item-more:focus-visible { opacity: 1; pointer-events: auto; @@ -455,12 +459,17 @@ /* Without hover there is nothing to reveal the row controls with, so a no-hover * pointer gets them visible and tappable outright. */ @media (hover: none) { - .sidebar-row-actions .thread-item-more, + .thread-item-meta .thread-item-more, .sidebar-menu-wrap .thread-item-more, .sidebar-session-group-add { opacity: 1; pointer-events: auto; } + + .thread-item-meta .thread-item-last-reply { + opacity: 0; + visibility: hidden; + } } .thread-item-more:hover, diff --git a/apps/desktop/src/styles/sidebar-threads.css b/apps/desktop/src/styles/sidebar-threads.css index def1ccacec..da54633eb2 100644 --- a/apps/desktop/src/styles/sidebar-threads.css +++ b/apps/desktop/src/styles/sidebar-threads.css @@ -43,23 +43,37 @@ white-space: nowrap; } -.thread-item-date-separator, -.thread-item-last-reply { +.thread-item-meta { + /* The action and date overlap in one slot; opacity keeps its width stable. */ + display: grid; + width: max-content; flex: 0 0 auto; + align-items: center; + justify-items: end; +} + +.thread-item-meta > * { + grid-area: 1 / 1; +} + +.thread-item-last-reply { color: var(--ds-text-muted); font-size: var(--text-sm); white-space: nowrap; + transition: + opacity var(--motion-duration-fast) var(--motion-ease-out), + visibility var(--motion-duration-fast) var(--motion-ease-out); } -.thread-item-date-separator { - margin-left: auto; -} - -.thread-item:hover .thread-item-date-separator, .thread-item:hover .thread-item-last-reply, -.thread-item:focus-within .thread-item-date-separator, .thread-item:focus-within .thread-item-last-reply { - display: none; + opacity: 0; + visibility: hidden; +} + +.thread-item-meta.is-menu-open .thread-item-last-reply { + opacity: 0; + visibility: hidden; } .thread-item:hover, diff --git a/apps/desktop/test/sidebar-navigation.test.mjs b/apps/desktop/test/sidebar-navigation.test.mjs index 4c439b3dfb..550429bd45 100644 --- a/apps/desktop/test/sidebar-navigation.test.mjs +++ b/apps/desktop/test/sidebar-navigation.test.mjs @@ -368,13 +368,17 @@ test("hidden row actions stay out of the row's click path", () => { ); assert.match( globalStyles, - /\.thread-item:focus-within \.thread-item-more,\s*\n\.thread-item-more:focus-visible\s*\{[^}]*pointer-events:\s*auto;/s, + /\.thread-item:hover \.thread-item-more,\s*\n\.thread-item:focus-within \.thread-item-more,[\s\S]*?\{[^}]*pointer-events:\s*auto;/s, + ); + assert.match( + globalStyles, + /\.thread-item-more:focus-visible\s*\{[^}]*opacity:\s*1;[^}]*pointer-events:\s*auto;/s, ); // Without hover there is no reveal, so a no-hover pointer gets the controls // visible and tappable instead of an invisible gutter. assert.match( globalStyles, - /@media \(hover: none\)\s*\{[\s\S]*?\.sidebar-row-actions \.thread-item-more,[\s\S]*?opacity:\s*1;\s*\n\s*pointer-events:\s*auto;/, + /@media \(hover: none\)\s*\{[\s\S]*?\.thread-item-meta \.thread-item-more,[\s\S]*?opacity:\s*1;\s*\n\s*pointer-events:\s*auto;/, ); // The row itself stays clickable where the hidden control used to swallow // the click, and spelled-out controls never double-fire the row. diff --git a/apps/desktop/test/sidebar-pinned-rendering.test.mjs b/apps/desktop/test/sidebar-pinned-rendering.test.mjs index 99642dfffd..60f93ae035 100644 --- a/apps/desktop/test/sidebar-pinned-rendering.test.mjs +++ b/apps/desktop/test/sidebar-pinned-rendering.test.mjs @@ -60,6 +60,7 @@ test("sidebar renders global pins once, outside project folding and history limi activeSessionId: null, selectingSessionId: null, page: "chat", + settings: { sidebarSessionTime: true }, projectCollapsed: {}, sessionView: { sort: "recent", archived: false }, }; @@ -152,8 +153,7 @@ test("sidebar renders global pins once, outside project folding and history limi const datedGroup = unpinned.match(/data-sidebar-project-group="\/dated"[\s\S]*?<\/section>/)?.[0] ?? ""; assert.deepEqual(rows(datedGroup), ["date-row"]); - assert.match(datedGroup, /class="thread-item-date-separator" aria-hidden="true">\|<\/span>/); - assert.match(datedGroup, /class="thread-item-last-reply">Jan 1, 2020<\/span>/); + assert.match(datedGroup, /class="thread-item-meta(?: is-menu-open)?"[\s\S]*?class="thread-item-last-reply">Jan 1<\/span>/); // A retained project with no sessions is the empty state, and it has to sit // in the same list layer so the fold animates it away too. diff --git a/apps/desktop/test/sidebar-session-date.test.mjs b/apps/desktop/test/sidebar-session-date.test.mjs index ffa769e8a1..885bcbc485 100644 --- a/apps/desktop/test/sidebar-session-date.test.mjs +++ b/apps/desktop/test/sidebar-session-date.test.mjs @@ -27,7 +27,7 @@ test("formats session activity through seven calendar days and uses a date after ); assert.equal( formatSessionLastReplyDate(atLocalDay(8), now, "en"), - "Oct 8, 2026", + "Oct 8", ); }); @@ -38,11 +38,11 @@ test("invalid timestamps do not render a misleading date", () => { test("project session rows show the date until hover or focus reveals actions", () => { assert.match(sidebarSource, /sidebarSessionTime === true/); - assert.match(sidebarSource, /className="thread-item-date-separator"/); + assert.match(sidebarSource, /className=\{cx\([\s\S]*?"thread-item-meta"[\s\S]*?sessionMenu === session\.id/); assert.match(sidebarSource, /className="thread-item-last-reply"/); assert.match( sidebarStyles, - /\.thread-item:hover \.thread-item-last-reply,[\s\S]*?\.thread-item:focus-within \.thread-item-last-reply\s*\{\s*display:\s*none;/, + /\.thread-item:hover \.thread-item-last-reply,[\s\S]*?\.thread-item:focus-within \.thread-item-last-reply\s*\{\s*opacity:\s*0;\s*visibility:\s*hidden;/, ); assert.match( sidebarStyles, diff --git a/apps/desktop/test/todo-dock-rendering.test.mjs b/apps/desktop/test/todo-dock-rendering.test.mjs index 81b7eb8328..ef3ab14b73 100644 --- a/apps/desktop/test/todo-dock-rendering.test.mjs +++ b/apps/desktop/test/todo-dock-rendering.test.mjs @@ -65,6 +65,10 @@ test("TodoDock renders bounded session progress and cancelled state", async () = assert.doesNotMatch(html, /more items/); assert.match(html, /class="todo-dock-list" role="list" tabindex="-1"/); assert.match(html, /lucide-chevron-up/); + assert.match( + html, + /]*class="[^"]*lucide-chevron-up")(?=[^>]*width="18")(?=[^>]*height="18")[^>]*>/, + ); assert.match(html, /lucide-check/); // The sweep rides the text of the working step only, and only once: while // collapsed it is the header's current-item line, because the list stays @@ -98,6 +102,10 @@ test("TodoDock renders bounded session progress and cancelled state", async () = test("TodoDock uses a quiet disclosure animation and state surfaces", async () => { const { readFile } = await import("node:fs/promises"); const css = await readFile(new URL("../src/styles/composer.css", import.meta.url), "utf8"); + assert.match( + css, + /\.todo-dock-toggle\.btn\s*\{[^}]*width:\s*36px;[^}]*min-width:\s*36px;[^}]*height:\s*36px;[^}]*flex:\s*0 0 36px;[^}]*padding:\s*0;/, + ); assert.match(css, /\.todo-dock-toggle:active:not\(:disabled\)\s*\{[\s\S]*?transform:\s*none;/); assert.match(css, /\.todo-dock-header-copy\s*\{[\s\S]*?flex:\s*1;[\s\S]*?align-items:\s*center;/); assert.match(css, /\.todo-dock-row-completed \.todo-dock-row-content\s*\{[\s\S]*?text-decoration:\s*line-through;/); diff --git a/docs/spec/04-ux/08-component-spec.md b/docs/spec/04-ux/08-component-spec.md index 6557501b45..0f770f5a64 100644 --- a/docs/spec/04-ux/08-component-spec.md +++ b/docs/spec/04-ux/08-component-spec.md @@ -441,6 +441,7 @@ visually distinct from list content. | Session completed | Green check mark from the latest unread task notification when the row is not selected | | Session failed | Red circled alert mark from the latest unread task notification when the row is not selected | | Hover row | Project headers and all conversation rows share one full-row `--ds-bg-hover` surface, radius and transition; selected conversation fill takes precedence | +| Project session metadata | When Session time is enabled, the trailing slot shows the last-reply date at rest and replaces it with session actions on hover or keyboard focus, without a divider. Dates within seven calendar days are relative; older dates show localized month and day without a year | | Current workspace | Project header shows the workspace dot, never a persistent selection fill; topbar follows that workspace; composer exposes no workspace identity | | Navigation selection | Only the selected conversation on the chat page uses `--ds-bg-active`, including pinned and standalone rows; folding its group never transfers selection to the header. No conversation or a non-chat page means no selected conversation row | | Keyboard focus | The focused control keeps its visible outline; the project title stays transparent and independent action buttons retain their own hover feedback | @@ -739,7 +740,7 @@ controls. | Project archive | omitted from default view; restorable from archived view | | Project delete | the row-menu danger action always opens a confirmation dialog naming the project and session count; X, Cancel, Escape, and backdrop dismiss without changes; explicit confirmation stops listed running sessions before removing the durable project row, owned sessions/transcripts, and project memory; the folder is never deleted; deleting a root of a stored multi-folder project group is refused until the root is detached through Edit project. Chats on a detached root remain available as a standalone project. A path the host no longer knows is still removed from the list. | | Project memory | row-menu editor reads and saves a compact list of titled or untitled memory cards for the exact project path; cards can be added, edited, and removed, the context is available in later chats, and it is never a higher-priority instruction | -| Session list | exact-path matches only; no basename grouping; the latest ten unpinned sessions appear initially without time-group headings, and Load more reveals the remaining history; each project row shows the title, a divider, and a localized last-reply age through seven calendar days or a concrete date afterward; hovering or focusing the row replaces that date with session actions; collapsing the project clears the expanded-list state so the next expansion returns to the initial ten | +| Session list | exact-path matches only; no basename grouping; the latest ten unpinned sessions appear initially without time-group headings, and Load more reveals the remaining history; when Session time is enabled, each project row shows the title and a localized last-reply age through seven calendar days or a month/day date afterward; hovering or focusing the row replaces that date with session actions in the same slot; collapsing the project clears the expanded-list state so the next expansion returns to the initial ten | | Active group | exactly one group reflects the selected host workspace | | Task state | In-progress, selected, completed, and failed indicators update by session without replacing the visible transcript; precedence is in-progress, selected, then terminal outcome | @@ -2954,8 +2955,9 @@ reasoning-level control. places an independent expand/collapse button at the right edge. Only that button toggles the list; the text area is not interactive. Its arrow points up while collapsed because the list opens upward from the Composer, then - points down while expanded. The current task title truncates when space is - limited. + points down while expanded. The button provides a 36px square hit target, + with its 18px arrow icon sized independently. The current task title truncates + when space is limited. A checklist whose items are all cancelled has a clear cancelled label instead of a misleading `0/0 completed` count. - The disclosure is keyboard accessible, does not take focus on updates, and diff --git a/docs/spec/04-ux/09-interaction-patterns.md b/docs/spec/04-ux/09-interaction-patterns.md index f2e6ca4a38..633d419be5 100644 --- a/docs/spec/04-ux/09-interaction-patterns.md +++ b/docs/spec/04-ux/09-interaction-patterns.md @@ -297,10 +297,11 @@ may be retained while exactly one workspace supplies the visible shell context. a stable path order until they are moved. - Each project group shows the ten most-recent rows in the active sort order by default; the remaining sessions fold behind a **Load N more…** control. - The expanded list remains flat. Each project session row shows its title and - localized last-reply age through seven calendar days, then a concrete date; - hover or keyboard focus replaces the date with the session actions. Expanded - state is per-group, for the current session only, and not persisted. + The expanded list remains flat. When Session time is enabled, each project + session row shows its title and localized last-reply age through seven + calendar days, then a month/day date; hover or keyboard focus replaces the + date with the session actions. Expanded state is per-group, for the current + session only, and not persisted. - Presentation changes are saved best-effort. Storage failure must not block project activation, session selection, or agent execution. @@ -1600,6 +1601,11 @@ Project drag/drop follows these patterns: and remain reachable through keyboard focus (`:focus-within` / `:focus-visible`) without a pointer. A spelled-out control never triggers the row or header underneath it as well. +- When Session time is enabled, project session dates and the overflow action + share one trailing slot: the date is visible at rest, then yields that slot + to the action on hover or keyboard focus. Dates through seven calendar days + use a relative day label; older dates use the localized month and day without + a year. On devices without hover, the action stays available in that slot. - The hover card's own navigation controls are the only interactive surfaces inside the card; the row keeps receiving pointer events everywhere else on it. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 0c0ddbdee8..41b076c6d2 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -10048,9 +10048,10 @@ must keep splitting are covered by `markdown-blocks.test.mjs`. - **Steps**: Start a multi-step Agent turn that calls `TodoWrite` with ordered pending and `in_progress` items. Observe the TodoDock above the Composer; confirm its progress text is left-aligned, its separate arrow button is at - the right edge, and clicking the text does not expand it. Click the upward - arrow to expand, then confirm the arrow points down. Switch sessions and - confirm the checklist stays session-scoped. + the right edge, measures at least 36px square, and contains an independently + sized 18px arrow icon. Confirm clicking the text does not expand it. Click + the upward arrow to expand, then confirm the arrow points down. Switch + sessions and confirm the checklist stays session-scoped. Expand a checklist longer than the dock's height cap: every row must render and the list must scroll inside the dock without growing the Composer stack. Complete and cancel items, confirm the all-cancelled label, then clear the @@ -16197,12 +16198,15 @@ plugin-form fixtures in an isolated temporary directory at runtime. #### E2E-LAYOUT-sidebar-row-states - **Preconditions**: Host-seeded project, pinned and standalone conversations; - a current workspace; built desktop with isolated data and profile directories. + the sidebar Session time preference enabled; a current workspace; built + desktop with isolated data and profile directories. - **Steps**: In dark and light themes, select a project conversation, hover its project title and an unselected conversation, then hover the selected row. Check that a project session date is visible at rest and replaced by its - action on hover and focus. Exercise the window-blur handler, drop-target - styling, project action hover and keyboard Tab/Shift+Tab focus. Fold and + action in the same trailing slot on hover and focus; verify no divider appears + before the date, dates through seven calendar days are relative, and older + dates show month and day without a year. Exercise the window-blur handler, + drop-target styling, project action hover and keyboard Tab/Shift+Tab focus. Fold and reopen its group. Select pinned and standalone conversations. Open Settings and return. Enable reduced motion and inspect both row transition durations. In a separate twelve-session project, @@ -16217,7 +16221,10 @@ plugin-form fixtures in an isolated temporary directory at runtime. wins over hover, and blur releases hover without clearing selection. Settings replaces sidebar navigation, and returning restores conversation and workspace context without a second selected row. Project session rows show dates at rest - and reveal actions in their place on hover and focus. Loading more sessions + in the same trailing slot used for session actions, with no divider. Hover and + focus replace the date with the action in that slot. Dates through seven + calendar days are relative and older dates show month and day without a year. + Loading more sessions reveals all twelve; folding and reopening restores the initial ten and the two-session remainder control. Rendered component tests additionally cover no selected session, a pending destination and non-chat page state. diff --git a/docs/zh-CN/spec/04-ux/08-component-spec.md b/docs/zh-CN/spec/04-ux/08-component-spec.md index 254078bb96..01c073eee5 100644 --- a/docs/zh-CN/spec/04-ux/08-component-spec.md +++ b/docs/zh-CN/spec/04-ux/08-component-spec.md @@ -354,6 +354,7 @@ Collapsed (48px): | 会话已完成 | 未选择该行时呈绿色复选标记 | | 会话失败 | 未选择该行时,红色圆圈警告标记 | | 行悬停 | 项目标题与所有会话行共享整行 `--ds-bg-hover` 背景、圆角和过渡;会话选中背景优先 | +| 项目会话元数据 | 启用“会话时间”时,尾部位置静止显示最后回复日期;悬停或键盘聚焦时在同一位置替换为会话操作,不带分隔线。七个日历日内显示相对天数;更早显示本地化的月和日,不带年份 | | 当前工作区 | 项目标题通过圆点标记当前工作区,不绘制持久选中背景;顶栏跟随该工作区,输入框不公开工作区身份 | | 导航选中 | 仅聊天页当前会话使用 `--ds-bg-active`,包括置顶和独立会话;折叠分组不会把选中态转交给标题。没有选中会话或离开聊天页时,没有选中的会话行 | | 键盘焦点 | 焦点控件保留可见轮廓;项目标题按钮保持透明,独立操作按钮保留自己的悬停反馈 | @@ -542,7 +543,7 @@ expanded/collapsed 侧边栏仍为 20px/18px 且启动画面为 64 像素。 | 项目档案 | 从默认视图中省略;可从存档视图恢复 | | 删除项目 | 项目菜单中的危险操作会立即打开确认对话框,显示项目名称和会话数量;点击 X、“取消”、按 Escape 或点击遮罩都会关闭对话框且不作更改;明确确认后先停止仍在运行的会话,再移除持久项目行、所属会话、转录本与项目记忆;不会删除磁盘文件夹;删除项目组根路径会被拒绝并提示;宿主已不再知晓的路径仍会从列表移除 | | 项目记忆 | 行菜单编辑器读取并保存一份紧凑的、按规范项目路径键控的有标题或无标题记忆卡列表;卡片可添加、编辑和移除,该上下文在后续聊天中可用,且它绝不是更高优先级的指令 | -| 会话列表 | 仅精确路径匹配;无基本名称分组;项目组初始显示最新的 10 条未置顶会话,“加载更多”显示剩余历史记录;折叠项目会清除该组的扩展状态,下次展开时恢复到初始的 10 条 | +| 会话列表 | 仅精确路径匹配;无基本名称分组;项目组初始显示最新的 10 条未置顶会话且不显示时间分组标题,“加载更多”显示剩余历史记录;启用“会话时间”时,每条项目会话显示标题及本地化最后回复时间:七个日历日内显示相对天数,之后显示月/日;悬停或聚焦会在同一位置把日期替换为会话操作;折叠项目会清除该组的扩展状态,下次展开时恢复到初始的 10 条 | | 活跃组 | 恰好一组反映了选定的主机工作区 | | 任务状态 | 正在进行、已选择、已完成和失败的指标按会话更新,无需替换可见的记录;优先级是进行中、选择、然后是最终结果 | @@ -2018,6 +2019,7 @@ MainChat 底部的输入区域,用于撰写和发送提示。支持多行输 **11.3a 会话 TodoDock** - 当活动会话拥有非空的主机清单时,TodoDock 位于 Composer 栈中并显示在 Plan/Goal 批准表面上方;空清单不占布局空间。 +- 标题行将进度和当前 `in_progress` 内容靠左排列,并在右侧放置独立的展开/收起按钮;只有该按钮切换清单,文本区不响应点击。清单收起时箭头向上,展开时向下。按钮点击区域为 36px 方形,箭头图标单独设为 18px。 - 收起标题显示完成/活动进度和当前 `in_progress` 内容;全部取消时显示明确的取消状态,不显示误导性的 `0/0 completed`。 - 展开后列出全部条目,并在自身范围内滚动(`max-height: min(280px, 30dvh)`、`overflow-y: auto`、`overscroll-behavior: contain`):长清单因此保持可读,既不撑高 Composer 栈,滚到底也不会带动背后的转写;该滚动区仅在展开时可被键盘聚焦。 - 列表在收起时仍保持挂载,但收起状态只占标题行高度(列表内边距被裁剪,不会在标题下方留白),通过有界的高度/透明度过渡播放展开和收起动画。 diff --git a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md index f3c8bdbe47..1277d6776b 100644 --- a/docs/zh-CN/spec/04-ux/09-interaction-patterns.md +++ b/docs/zh-CN/spec/04-ux/09-interaction-patterns.md @@ -226,11 +226,11 @@ 至 `recent`。按住项目标题并移动 8px,或聚焦标题后按 `ArrowUp`/`ArrowDown`,会切换到 `manual` 项目排序,并按规范化路径持久化连续顺序。 归档和置顶优先级仍高于手动顺序;尚未分配顺序的项目暂按稳定路径顺序排列。 -- 每个项目组显示活动排序顺序中最新的十行 - 默认情况下;剩余的会话折叠在 **加载 N 更多…** 控件后面 - (与时间分组溢出相同的可供性)。选择它展开 - 完整的时间分组列表,并且扩展状态是每个组的,对于 -仅当前会话,不持久。 +- 每个项目组默认显示当前排序中最新的十条会话,剩余历史折叠在 + **加载 N 更多…** 控件后;展开列表保持平铺。启用“会话时间”时, + 每条项目会话显示标题和本地化最后回复时间:七个日历日内显示相对天数, + 更早日期显示月/日;悬停或键盘聚焦会在同一尾部位置用会话操作替换日期。 + 展开状态按项目组保存,仅对当前会话有效,不持久化。 - 尽力保存演示文稿更改。存储故障不得阻塞 项目激活、会话选择或代理执行。 diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md index 3fb4276b46..9bead491a3 100644 --- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md @@ -5712,7 +5712,7 @@ eleven-tool-round desktop paths are verified by **E2E-CHAT-session-todo-checklist:TodoWrite 到按会话显示的 TodoDock** - **前提:** 隔离的本地 Electron 配置、确定性的 Agent/Host fixture、两个 Desktop 会话,不使用真实 Provider 或付费 API。 -- **步骤:** 启动调用 `TodoWrite` 的多步骤 Agent 回合,观察 Composer 上方的 TodoDock,展开后切换会话并确认清单隔离。展开一个超过 dock 高度上限的清单,确认渲染全部条目、列表在 dock 内滚动而不撑高 Composer 栈;完成和取消条目,确认全部取消状态;清空清单后重载/重启 Host。发送乱序旧 `todos.changed` 事件,确认它不能覆盖新快照;再覆盖非法参数、Plan/Goal、委托和远程会话路径。 +- **步骤:** 启动调用 `TodoWrite` 的多步骤 Agent 回合,观察 Composer 上方的 TodoDock;确认右侧按钮至少为 36px 方形点击区域,箭头图标独立设为 18px,点击标题文本不会展开。点击向上箭头展开并确认箭头转向下方;切换会话并确认清单隔离。展开一个超过 dock 高度上限的清单,确认渲染全部条目、列表在 dock 内滚动而不撑高 Composer 栈;完成和取消条目,确认全部取消状态;清空清单后重载/重启 Host。发送乱序旧 `todos.changed` 事件,确认它不能覆盖新快照;再覆盖非法参数、Plan/Goal、委托和远程会话路径。 - **预期:** 切换会话时收起、收起状态只占标题行高度、列表渲染全部条目并以自身为滚动容器、拒绝旧事件。展开后是纯文本清单,状态用字形标记而非带边框的方块;进行中的那一步文字带一道扫光,且任一时刻只有一处文字带扫光(收起时在标题行,展开时在对应行)。 - **链接规格:** `03-runtime/03-tools-and-permissions.md`、`03-runtime/04-data-storage.md`、`03-runtime/06-host-rpc-protocol.md`、`04-ux/08-component-spec.md`、ADR 0312。 - **验收:** C / E / F / Quality / Security。 @@ -8498,14 +8498,16 @@ runner 会在运行时的隔离临时目录中生成六个插件形态 fixture #### E2E-LAYOUT-sidebar-row-states -- **前提条件**:宿主预置项目、置顶和独立会话,存在当前工作区;应用使用隔离的数据与 profile。 +- **前提条件**:宿主预置项目、置顶和独立会话,启用侧边栏“会话时间”偏好,存在当前工作区;应用使用隔离的数据与 profile。 - **步骤**:分别在深浅主题下选中项目会话,悬停项目标题、未选中和已选中会话; + 确认项目会话日期静止可见,并在悬停和键盘焦点时于同一尾部位置切换为操作,日期左侧没有分隔线;验证七个日历日内显示相对天数,更早显示不含年份的月/日; 检查失焦处理、拖拽目标样式、操作按钮悬停及 Tab/Shift+Tab 焦点;折叠后展开 选中会话的分组,切换置顶与独立会话,打开设置再返回;开启减少动态效果, 确认项目与会话行的悬停过渡均接近零时长。 - **预期**:项目与会话共享整行悬停背景、圆角和过渡,标题按钮透明;选中背景 只属于会话且优先于悬停。工作区仅通过圆点表达,折叠不会转移选中态。 置顶和独立会话样式一致;焦点轮廓、独立操作按钮反馈和拖拽目标优先级保留。 + 项目会话日期默认可见且与操作共享尾部位置,悬停或聚焦后由操作替换,日期左侧无分隔线。 失焦释放悬停而不清除选中;设置替换侧栏,返回后恢复会话与工作区上下文。 渲染测试另覆盖没有选中会话、切换中的目标和非聊天页状态。 - **链接规格**:`04-ux/01-ui-ia.md`、`04-ux/08-component-spec.md`、`04-ux/09-interaction-patterns.md` §9.1c diff --git a/scripts/e2e-three-column-layout.mjs b/scripts/e2e-three-column-layout.mjs index 70b2a629c5..c276f3e5c5 100644 --- a/scripts/e2e-three-column-layout.mjs +++ b/scripts/e2e-three-column-layout.mjs @@ -70,10 +70,9 @@ function resolveHostBinary() { * history twelve unpinned sessions for the project load-more reset flow * * `session.import` is the only protocol entry point that accepts a session's own - * timestamps, which is what puts rows in the yesterday / this-week / older - * buckets. The retained-tab list, the pin set, and the project ordering are - * renderer-local, so they are handed to the app through its own persisted - * sidebar preferences. + * timestamps, which supplies the relative and calendar-date row labels. The + * retained-tab list, the pin set, and the project ordering are renderer-local, + * so they are handed to the app through its own persisted sidebar preferences. * * `tempDirs` receives the project root as soon as it exists, so a failure here * still leaves the caller's clean-up able to remove it. @@ -112,6 +111,7 @@ async function seedSidebarProjects(hostBinary, dataDir, tempDirs) { const host = new Host(hostBinary, dataDir); try { await host.start(); + await host.call("settings.set", { sidebarSessionTime: true }); await host.call("workspace.set", { path: alpha }); const importRow = async (id, title, projectPath, updatedMs) => { const result = await host.call("session.import", { diff --git a/scripts/e2e/sidebar-row-states.mjs b/scripts/e2e/sidebar-row-states.mjs index b833d5d529..543e0828a4 100644 --- a/scripts/e2e/sidebar-row-states.mjs +++ b/scripts/e2e/sidebar-row-states.mjs @@ -142,13 +142,15 @@ export async function checkSidebarRowStates({ cdp, check, waitFor, seed }) { })()`); const sessionDateActions = (id) => cdp.evaluate(`(() => { const row = document.querySelector(${JSON.stringify(row(id))}); + const meta = row?.querySelector('.thread-item-meta'); const date = row?.querySelector('.thread-item-last-reply'); - const separator = row?.querySelector('.thread-item-date-separator'); const action = row?.querySelector('[data-action="session-menu"]'); return { dateText: date?.textContent?.trim() ?? "", - dateDisplay: date ? getComputedStyle(date).display : "missing", - separatorDisplay: separator ? getComputedStyle(separator).display : "missing", + dateVisibility: date ? getComputedStyle(date).visibility : "missing", + dateOpacity: date ? Number.parseFloat(getComputedStyle(date).opacity) : -1, + sameSlot: Boolean(meta && date?.parentElement === meta && action?.parentElement === meta), + noDivider: !row?.querySelector('.thread-item-date-separator'), actionOpacity: action ? Number.parseFloat(getComputedStyle(action).opacity) : -1, actionPointerEvents: action ? getComputedStyle(action).pointerEvents : "missing", }; @@ -252,18 +254,19 @@ export async function checkSidebarRowStates({ cdp, check, waitFor, seed }) { await hover(`${row(datedRowId)} .thread-item-main`); const dateOnHover = await sessionDateActions(datedRowId); check( - dateAtRest.dateText.length > 0 && dateAtRest.dateDisplay !== "none" && - dateAtRest.separatorDisplay !== "none" && dateAtRest.actionOpacity === 0 && - dateAtRest.actionPointerEvents === "none" && dateOnHover.dateDisplay === "none" && - dateOnHover.separatorDisplay === "none" && dateOnHover.actionOpacity === 1 && + dateAtRest.dateText.length > 0 && dateAtRest.dateVisibility === "visible" && + dateAtRest.dateOpacity === 1 && dateAtRest.sameSlot && dateAtRest.noDivider && + dateAtRest.actionOpacity === 0 && + dateAtRest.actionPointerEvents === "none" && dateOnHover.dateVisibility === "hidden" && + dateOnHover.dateOpacity === 0 && dateOnHover.sameSlot && dateOnHover.actionOpacity === 1 && dateOnHover.actionPointerEvents === "auto", - `${theme} project session rows swap the resting date for actions on hover`, + `${theme} project session rows swap the resting date for actions in one slot on hover`, JSON.stringify({ dateAtRest, dateOnHover }), ); await cdp.evaluate(`document.querySelector(${JSON.stringify(row(datedRowId) + " .thread-item-main")}).focus()`); const dateOnFocus = await sessionDateActions(datedRowId); check( - dateOnFocus.dateDisplay === "none" && dateOnFocus.actionOpacity === 1 && + dateOnFocus.dateVisibility === "hidden" && dateOnFocus.actionOpacity === 1 && dateOnFocus.actionPointerEvents === "auto", `${theme} keyboard focus reveals session actions in place of the date`, JSON.stringify(dateOnFocus), diff --git a/scripts/e2e/todo-checklist.tsx b/scripts/e2e/todo-checklist.tsx index 547e9fccde..b98d9b3d16 100644 --- a/scripts/e2e/todo-checklist.tsx +++ b/scripts/e2e/todo-checklist.tsx @@ -65,6 +65,13 @@ window.todoChecklistProbe = async () => { assert(toggle()?.getAttribute("aria-expanded") === "false", "Dock starts collapsed"); assert(toggle()?.querySelector("svg")?.classList.contains("lucide-chevron-up"), "Collapsed dock arrow points toward its upward expansion"); + const toggleRect = toggle()!.getBoundingClientRect(); + assert(toggleRect.width >= 36 && toggleRect.height >= 36, + `Expand/collapse button has a 36px minimum hit target (${toggleRect.width}x${toggleRect.height})`); + const arrowRect = toggle()!.querySelector("svg")!.getBoundingClientRect(); + assert(Math.round(arrowRect.width) === 18 && Math.round(arrowRect.height) === 18, + `Expand/collapse icon is independently sized to 18px (${arrowRect.width}x${arrowRect.height})`); + checks.push("disclosure-hit-target-and-icon-sized-independently"); // The closed disclosure must not reserve the list's inset: vertical padding on // the collapsing box held the `0fr` row open and left a blank band under it. assert(Math.round(box(".todo-dock-content").height) === 0,