From 380d9ae3cb835fcb59c7fead4d32788640b3aae3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 23 Sep 2026 23:53:46 +0800 Subject: [PATCH 1/4] fix(ui): titlebar tooltips reveal, not repeat, the session name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hovering the title chip or the "..." button re-announced the whole session name plus an action tail — no new information, longer wrap. The name tooltip now appears only when the visible name is ellipsized and shows exactly the hidden text; the "..." tooltip is the action alone. Accessible names keep the name plus action suffix, so screen-reader users lose nothing. The truncation could not be measured before: the segment span was display:inline, whose scrollWidth/clientWidth are always 0. Make it a constrained block (same recipe as quote-ref-chip) so the ellipsis is real layout the hook can observe via ResizeObserver across resizes. New unit test pins the untruncated case (no tooltip) and the menu tooltip; the titlebar-identity-truncated story gains a play that hovers both triggers on real layout and asserts the tooltip accessible names. Generated-by: Maka --- .../src/renderer/styles/shell-layout.css | 3 + apps/desktop/stories/app-shell.stories.tsx | 21 +++- .../titlebar-session-identity.test.tsx | 114 ++++++++++++++++++ packages/ui/src/titlebar-session-identity.tsx | 43 ++++++- 4 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/__tests__/titlebar-session-identity.test.tsx diff --git a/apps/desktop/src/renderer/styles/shell-layout.css b/apps/desktop/src/renderer/styles/shell-layout.css index 80c0bfb6db..4e39e6f525 100644 --- a/apps/desktop/src/renderer/styles/shell-layout.css +++ b/apps/desktop/src/renderer/styles/shell-layout.css @@ -389,6 +389,9 @@ dialog:modal { } .maka-titlebar-identity__segment--session { + display: block; + min-width: 0; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 2ee696bbc2..2ed2dadc7d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1833,16 +1833,33 @@ export const TitlebarParentReturn: Story = { // Real path: a long auto-generated session name, sidebar collapsed so the // identity sits closest to the conversation column. It must truncate itself -// rather than push the workbar toggle off the strip. +// rather than push the workbar toggle off the strip. Tripled so the name stays +// clipped at the smoke runner's default 1280px viewport, not just narrow ones. export const TitlebarIdentityTruncated: Story = { render: () => ( ), + play: async ({ canvasElement }) => { + const full = 'Chat Surface 会话上下文在极窄窗口中的响应式收敛与信息优先级验证'.repeat(3); + const page = within(canvasElement.ownerDocument.body); + const rename = canvasElement.querySelector('.maka-titlebar-identity__segment--session')!.closest('button')!; + expect(rename.getAttribute('aria-label')).toBe(`${full} — 重命名任务`); + // Truncated name: hovering reveals exactly the hidden text. The tooltip + // must never grow a “ — 重命名任务” tail onto it. + await userEvent.hover(rename); + await waitFor(() => expect(page.getByRole('tooltip', { name: full })).toBeVisible()); + // The “...” button's accessible name carries the task it acts on; its + // tooltip must not echo the name displayed beside it. + const menuButton = canvasElement.querySelector('[aria-label$="任务操作"]')!; + expect(menuButton.getAttribute('aria-label')).toBe(`${full} — 任务操作`); + await userEvent.hover(menuButton); + await waitFor(() => expect(page.getByRole('tooltip', { name: '任务操作' })).toBeVisible()); + }, }; // Real path: 开启 Plan Mode from the + menu. The mode is session-scoped — it diff --git a/packages/ui/src/__tests__/titlebar-session-identity.test.tsx b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx new file mode 100644 index 0000000000..a6ff52ee15 --- /dev/null +++ b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Titlebar tooltips must add information, not repeat what is on screen. + * The name button shows the full name only when ellipsis truncated it; the + * "..." button never repeats the name. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { afterEach } from 'node:test'; +import { act, StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { LocaleProvider } from '../locale-context.js'; +import { TitlebarSessionIdentity } from '../titlebar-session-identity.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + matchMedia: globalThis.matchMedia, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +const mountedRoots: ReturnType[] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function domRoot() { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => + new Proxy( + { direction: 'ltr', writingMode: 'horizontal-tb', getPropertyValue: () => '' }, + { get: (target, key) => (key in target ? target[key as keyof typeof target] : '') }, + ) as unknown as CSSStyleDeclaration; + window.matchMedia = () => + ({ matches: false, addEventListener() {}, removeEventListener() {} }) as unknown as MediaQueryList; + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + return { container, root } as { + container: HTMLElement; + root: ReturnType; + }; +} + +async function renderIdentity(sessionName: string) { + const { container, root } = domRoot(); + await act(() => { + root.render( + + + undefined} + project={{ name: 'p' }} + /> + + , + ); + }); + return container; +} + +test('an untruncated session name gets no tooltip', async () => { + const container = await renderIdentity('Short name'); + const nameButton = container.querySelector('.maka-titlebar-identity__name'); + assert.ok(nameButton); + assert.equal(nameButton.getAttribute('aria-label'), 'Short name — Rename task'); + // A fully visible name must not produce a tooltip at all. + const tooltipLight = container.querySelector('.maka-tooltip, [id*="tooltip"]'); + assert.equal(nameButton.getAttribute('title'), null); + assert.equal(tooltipLight, null); +}); +test('the task-actions button never repeats the session name in its tooltip', async () => { + const longName = ' brew — a long task title about Homebrew updates that overflows the titlebar chip '; + const container = await renderIdentity(longName); + const menuButton = container.querySelector('[aria-label$=" — Task actions"]'); + assert.ok(menuButton); + assert.equal(menuButton.getAttribute('aria-label'), `${longName} — Task actions`); + assert.equal(menuButton.getAttribute('title'), null); +}); diff --git a/packages/ui/src/titlebar-session-identity.tsx b/packages/ui/src/titlebar-session-identity.tsx index 126d837a7c..9f96886ce0 100644 --- a/packages/ui/src/titlebar-session-identity.tsx +++ b/packages/ui/src/titlebar-session-identity.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Button } from '@astryxdesign/core/Button'; import { DropdownMenu, DropdownMenuItem } from '@astryxdesign/core/DropdownMenu'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -38,6 +38,34 @@ export interface TitlebarParentSession { onOpen(): void; } +/** + * Whether an ellipsis-clipped label is actually cut off. Astryx Button wraps + * children in an internal label span, so the measurement must happen on the + * text node itself (same recipe as quote-ref-chip). The ellipsis styles live + * in the app shell (`maka-titlebar-identity__segment--session`). + */ +function isNameClipped(el: HTMLElement | null): boolean { + return !!el && el.scrollWidth > el.clientWidth + 1; +} + +/** + * Track clipping across resize/layout changes so the tooltip appears exactly + * when the visible name is truncated — and never repeats a fully visible one. + */ +function useNameClipped(sessionName: string) { + const measureRef = useRef(null); + const [clipped, setClipped] = useState(false); + useLayoutEffect(() => { + const el = measureRef.current; + setClipped(isNameClipped(el)); + if (!el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => setClipped(isNameClipped(el))); + observer.observe(el); + return () => observer.disconnect(); + }, [sessionName]); + return { measureRef, clipped }; +} + export function TitlebarSessionIdentity(props: { sessionName: string; onRenameSession(name: string): void; @@ -66,6 +94,11 @@ export function TitlebarSessionIdentity(props: { }, [renaming]); const path = props.project?.path; + const { measureRef, clipped } = useNameClipped(props.sessionName); + // Show the full name only when ellipsis cut it off; repeating a fully + // visible title adds nothing. Rename affordance lives in the accessible + // label and the "..." menu, not in a run-on tooltip sentence. + const nameTooltip = clipped ? props.sessionName : undefined; const copyPhase = path ? clipboard.phaseFor(path) : null; const copyLabel = copyPhase === 'pending' ? copy.messages.copying : copyPhase === 'failed' ? copy.messages.copyFailed @@ -128,7 +161,7 @@ export function TitlebarSessionIdentity(props: { onCancel={() => endRename(true)} /> ) : props.readOnly ? ( - + {props.sessionName} ) : ( @@ -136,19 +169,19 @@ export function TitlebarSessionIdentity(props: { ref={nameRef} className="maka-titlebar-identity__name" label={`${props.sessionName} — ${copy.sessions.renameAriaLabel}`} - tooltip={`${props.sessionName} — ${copy.sessions.renameAriaLabel}`} + tooltip={nameTooltip} variant="ghost" size="sm" onClick={() => setRenaming(true)} > - {props.sessionName} + {props.sessionName} )} {!props.readOnly || props.action || (props.parentSession && props.project) ? ( , isIconOnly: true, variant: 'ghost', size: 'sm' }} + button={{ label: `${props.sessionName} — ${copy.chat.taskActions}`, tooltip: copy.chat.taskActions, icon: , isIconOnly: true, variant: 'ghost', size: 'sm' }} hasChevron={false} alignment="end" isMenuOpen={actionsMenuOpen} From ce004838b31cb37e5f0da4873e527a8e7fab4324 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 09:35:42 +0800 Subject: [PATCH 2/4] fix(ui): visible title chips hint rename instead of going tooltip-less MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: a fully visible chip's missing information is the click affordance, not nothing — show 重命名任务 on hover while the name is intact, and keep the full-name reveal for the truncated state. Read-only chips stay truncation- gated: they are not clickable, so the rename hint would lie there. The titlebar-project-feedback-narrow play pins the visible-state tooltip. Generated-by: Maka --- apps/desktop/stories/app-shell.stories.tsx | 5 +++ .../titlebar-session-identity.test.tsx | 38 +++++++++++++++---- packages/ui/src/titlebar-session-identity.tsx | 10 ++--- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 2ed2dadc7d..a6a52a2c81 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1790,6 +1790,11 @@ export const TitlebarProjectFeedbackNarrow: Story = { expect(writeText).toHaveBeenLastCalledWith('/workspace/maka-agent'); await userEvent.keyboard('{Escape}'); expect(document.activeElement).toBe(page.getByRole('button', { name: '项目信息' })); + // The chip is fully visible here, so the hover reveals the rename + // affordance — the one thing the surface does not show. + const renameChip = page.getByRole('button', { name: '检查项目菜单 — 重命名任务' }); + await userEvent.hover(renameChip); + await waitFor(() => expect(page.getByRole('tooltip', { name: '重命名任务' })).toBeVisible()); } finally { if (original) Object.defineProperty(navigator, 'clipboard', original); else Reflect.deleteProperty(navigator, 'clipboard'); diff --git a/packages/ui/src/__tests__/titlebar-session-identity.test.tsx b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx index a6ff52ee15..90a140e94d 100644 --- a/packages/ui/src/__tests__/titlebar-session-identity.test.tsx +++ b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx @@ -18,9 +18,12 @@ */ /** - * Titlebar tooltips must add information, not repeat what is on screen. - * The name button shows the full name only when ellipsis truncated it; the - * "..." button never repeats the name. + * Titlebar tooltips must add information, not repeat what is on screen: + * the name chip hints the rename action while fully visible and reveals the + * full text once ellipsis truncates it; the "..." button never repeats the + * name. Truncation gating itself needs real layout, so this fake DOM only + * pins the wiring — the tooltip contents are asserted in the browser tier + * (`TitlebarIdentityTruncated` / `TitlebarProjectFeedbackNarrow` plays). */ import assert from 'node:assert/strict'; @@ -94,16 +97,35 @@ async function renderIdentity(sessionName: string) { return container; } -test('an untruncated session name gets no tooltip', async () => { +test('an untruncated session name keeps the rename hint configured on the chip', async () => { const container = await renderIdentity('Short name'); const nameButton = container.querySelector('.maka-titlebar-identity__name'); assert.ok(nameButton); assert.equal(nameButton.getAttribute('aria-label'), 'Short name — Rename task'); - // A fully visible name must not produce a tooltip at all. - const tooltipLight = container.querySelector('.maka-tooltip, [id*="tooltip"]'); - assert.equal(nameButton.getAttribute('title'), null); - assert.equal(tooltipLight, null); + // Tooltip is always configured (rename hint when visible, full name when + // truncated); only its content switches. aria-describedby proves it is + // attached; the visible content is the story plays' job. + const nameSpan = container.querySelector('.maka-titlebar-identity__segment--session'); + assert.ok(nameSpan); + assert.notEqual(nameButton.getAttribute('aria-describedby'), null); + // The read-only branch's native title stays truncation-gated. + const readOnly = await renderReadOnly('Short name'); + assert.equal(readOnly.getAttribute('title'), null); }); + +async function renderReadOnly(sessionName: string) { + const { container, root } = domRoot(); + await act(() => { + root.render( + + + undefined} readOnly /> + + , + ); + }); + return container.querySelector('.maka-titlebar-identity__segment--session')!; +} test('the task-actions button never repeats the session name in its tooltip', async () => { const longName = ' brew — a long task title about Homebrew updates that overflows the titlebar chip '; const container = await renderIdentity(longName); diff --git a/packages/ui/src/titlebar-session-identity.tsx b/packages/ui/src/titlebar-session-identity.tsx index 9f96886ce0..36914c0bed 100644 --- a/packages/ui/src/titlebar-session-identity.tsx +++ b/packages/ui/src/titlebar-session-identity.tsx @@ -95,10 +95,10 @@ export function TitlebarSessionIdentity(props: { const path = props.project?.path; const { measureRef, clipped } = useNameClipped(props.sessionName); - // Show the full name only when ellipsis cut it off; repeating a fully - // visible title adds nothing. Rename affordance lives in the accessible - // label and the "..." menu, not in a run-on tooltip sentence. - const nameTooltip = clipped ? props.sessionName : undefined; + // One control, two kinds of hidden information: with the name truncated, + // what the reader cannot see is the full text; fully visible, the missing + // piece is the click affordance — rename. Never the name only. + const nameTooltip = clipped ? props.sessionName : copy.sessions.renameAriaLabel; const copyPhase = path ? clipboard.phaseFor(path) : null; const copyLabel = copyPhase === 'pending' ? copy.messages.copying : copyPhase === 'failed' ? copy.messages.copyFailed @@ -161,7 +161,7 @@ export function TitlebarSessionIdentity(props: { onCancel={() => endRename(true)} /> ) : props.readOnly ? ( - + {props.sessionName} ) : ( From 250df324134d40ecbe96c3e4f9b44b8eea29f4de Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 10:18:53 +0800 Subject: [PATCH 3/4] fix(ui): rebind titlebar truncation measurement to the rendered identity node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review (Codex, PR #5639): two lifecycle holes let the clipped flag describe a node that no longer exists. The read-only span never carried the measure ref, so shared-session titles could never regain their full-name title after the gating replaced the always-on one; and entering/leaving the rename input swapped the span without any sessionName change, leaving the observer on the unmounted node — the next resize could desync tooltip and layout. Rebind in the render-phase effect (inactive = renaming) and attach the ref in the read-only branch. The unit test now records which nodes the ResizeObserver sees, so a missing binding fails immediately, and the narrow titlebar play walks a rename round-trip before its hover assertion. Generated-by: Maka --- apps/desktop/stories/app-shell.stories.tsx | 9 +++++++ .../titlebar-session-identity.test.tsx | 25 +++++++++++++++++++ packages/ui/src/titlebar-session-identity.tsx | 13 ++++++---- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index a6a52a2c81..580b2493d5 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1790,6 +1790,15 @@ export const TitlebarProjectFeedbackNarrow: Story = { expect(writeText).toHaveBeenLastCalledWith('/workspace/maka-agent'); await userEvent.keyboard('{Escape}'); expect(document.activeElement).toBe(page.getByRole('button', { name: '项目信息' })); + // Swap the chip for the rename input and back without renaming: the + // measurement must rebind to the freshly rendered span (it was the + // observed one only before the swap), otherwise his hover would still + // read a tooltip bound to a dead layout. + await userEvent.click(page.getByRole('button', { name: '检查项目菜单 — 重命名任务' })); + const renameInput = await page.findByRole('textbox', { name: '重命名任务' }); + expect(renameInput).toHaveValue('检查项目菜单'); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(document.activeElement).toBe(page.getByRole('button', { name: '检查项目菜单 — 重命名任务' }))); // The chip is fully visible here, so the hover reveals the rename // affordance — the one thing the surface does not show. const renameChip = page.getByRole('button', { name: '检查项目菜单 — 重命名任务' }); diff --git a/packages/ui/src/__tests__/titlebar-session-identity.test.tsx b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx index 90a140e94d..230e13ca61 100644 --- a/packages/ui/src/__tests__/titlebar-session-identity.test.tsx +++ b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx @@ -113,6 +113,31 @@ test('an untruncated session name keeps the rename hint configured on the chip', assert.equal(readOnly.getAttribute('title'), null); }); +test('every branch binds the rendered text node for measurement, including read-only', async () => { + const observed: unknown[] = []; + const originalObserver = globalThis.ResizeObserver; + class RecordingObserver { + observe(el: unknown) { observed.push(el); } + unobserve() {} + disconnect() {} + } + globalThis.ResizeObserver = RecordingObserver as unknown as typeof ResizeObserver; + try { + const container = await renderIdentity('Short name'); + const editableSpan = container.querySelector('.maka-titlebar-identity__segment--session'); + assert.ok(editableSpan); + assert.ok(observed.includes(editableSpan), 'editable branch must observe its rendered span'); + observed.length = 0; + const readOnlySpan = await renderReadOnly('A long shared-session title that would not fit the chip width at all'); + // Regression: the read-only branch once rendered its span without the + // measure ref, so `clipped` stayed false forever and the native title + // disappeared from shared sessions. + assert.ok(observed.includes(readOnlySpan), 'read-only branch must bind the measure ref'); + } finally { + globalThis.ResizeObserver = originalObserver; + } +}); + async function renderReadOnly(sessionName: string) { const { container, root } = domRoot(); await act(() => { diff --git a/packages/ui/src/titlebar-session-identity.tsx b/packages/ui/src/titlebar-session-identity.tsx index 36914c0bed..431191c392 100644 --- a/packages/ui/src/titlebar-session-identity.tsx +++ b/packages/ui/src/titlebar-session-identity.tsx @@ -51,18 +51,21 @@ function isNameClipped(el: HTMLElement | null): boolean { /** * Track clipping across resize/layout changes so the tooltip appears exactly * when the visible name is truncated — and never repeats a fully visible one. + * The observed node is whichever span the current branch rendered; renaming + * swaps the span for an input and back, so the render phase must rebind on + * every such swap (`inactive`), not only when the name text changes. */ -function useNameClipped(sessionName: string) { +function useNameClipped(sessionName: string, inactive: boolean) { const measureRef = useRef(null); const [clipped, setClipped] = useState(false); useLayoutEffect(() => { const el = measureRef.current; setClipped(isNameClipped(el)); - if (!el || typeof ResizeObserver === 'undefined') return; + if (!el || inactive || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => setClipped(isNameClipped(el))); observer.observe(el); return () => observer.disconnect(); - }, [sessionName]); + }, [sessionName, inactive]); return { measureRef, clipped }; } @@ -94,7 +97,7 @@ export function TitlebarSessionIdentity(props: { }, [renaming]); const path = props.project?.path; - const { measureRef, clipped } = useNameClipped(props.sessionName); + const { measureRef, clipped } = useNameClipped(props.sessionName, renaming); // One control, two kinds of hidden information: with the name truncated, // what the reader cannot see is the full text; fully visible, the missing // piece is the click affordance — rename. Never the name only. @@ -161,7 +164,7 @@ export function TitlebarSessionIdentity(props: { onCancel={() => endRename(true)} /> ) : props.readOnly ? ( - + {props.sessionName} ) : ( From 2cb8eda2a820c8db41d0fbd8d138b4fd762610a3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 24 Sep 2026 10:36:11 +0800 Subject: [PATCH 4/4] fix(ui): trim titlebar tooltip coverage to what each layer owns Drop the unit tests whose content the story plays already assert on real layout; keep only the measurement-binding test, which is the one claim the famous-Node harness is decisive for (both branches must hand their rendered span to the observer). Shorten the prose comments to the two things a maintainer could otherwise get wrong: the span-swap rebinding and the two-state tooltip rule. Generated-by: Maka --- apps/desktop/stories/app-shell.stories.tsx | 13 +-- .../titlebar-session-identity.test.tsx | 83 ++++--------------- packages/ui/src/titlebar-session-identity.tsx | 23 ++--- 3 files changed, 24 insertions(+), 95 deletions(-) diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 580b2493d5..795babec2b 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1790,17 +1790,14 @@ export const TitlebarProjectFeedbackNarrow: Story = { expect(writeText).toHaveBeenLastCalledWith('/workspace/maka-agent'); await userEvent.keyboard('{Escape}'); expect(document.activeElement).toBe(page.getByRole('button', { name: '项目信息' })); - // Swap the chip for the rename input and back without renaming: the - // measurement must rebind to the freshly rendered span (it was the - // observed one only before the swap), otherwise his hover would still - // read a tooltip bound to a dead layout. + // Swap the chip for the rename input and back without renaming, then + // hover: the measurement must have rebound to the freshly rendered + // span, not the node observed before the swap. await userEvent.click(page.getByRole('button', { name: '检查项目菜单 — 重命名任务' })); const renameInput = await page.findByRole('textbox', { name: '重命名任务' }); expect(renameInput).toHaveValue('检查项目菜单'); await userEvent.keyboard('{Escape}'); await waitFor(() => expect(document.activeElement).toBe(page.getByRole('button', { name: '检查项目菜单 — 重命名任务' }))); - // The chip is fully visible here, so the hover reveals the rename - // affordance — the one thing the surface does not show. const renameChip = page.getByRole('button', { name: '检查项目菜单 — 重命名任务' }); await userEvent.hover(renameChip); await waitFor(() => expect(page.getByRole('tooltip', { name: '重命名任务' })).toBeVisible()); @@ -1863,12 +1860,8 @@ export const TitlebarIdentityTruncated: Story = { const page = within(canvasElement.ownerDocument.body); const rename = canvasElement.querySelector('.maka-titlebar-identity__segment--session')!.closest('button')!; expect(rename.getAttribute('aria-label')).toBe(`${full} — 重命名任务`); - // Truncated name: hovering reveals exactly the hidden text. The tooltip - // must never grow a “ — 重命名任务” tail onto it. await userEvent.hover(rename); await waitFor(() => expect(page.getByRole('tooltip', { name: full })).toBeVisible()); - // The “...” button's accessible name carries the task it acts on; its - // tooltip must not echo the name displayed beside it. const menuButton = canvasElement.querySelector('[aria-label$="任务操作"]')!; expect(menuButton.getAttribute('aria-label')).toBe(`${full} — 任务操作`); await userEvent.hover(menuButton); diff --git a/packages/ui/src/__tests__/titlebar-session-identity.test.tsx b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx index 230e13ca61..d676e6d6f6 100644 --- a/packages/ui/src/__tests__/titlebar-session-identity.test.tsx +++ b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx @@ -18,17 +18,14 @@ */ /** - * Titlebar tooltips must add information, not repeat what is on screen: - * the name chip hints the rename action while fully visible and reveals the - * full text once ellipsis truncates it; the "..." button never repeats the - * name. Truncation gating itself needs real layout, so this fake DOM only - * pins the wiring — the tooltip contents are asserted in the browser tier - * (`TitlebarIdentityTruncated` / `TitlebarProjectFeedbackNarrow` plays). + * Every branch of TitlebarSessionIdentity must hand its rendered text node to + * the truncation measurement — the read-only branch once rendered without the + * measure ref, leaving shared-session titles without their full-name tooltip. + * Tooltip contents themselves need real layout; the story plays own them. */ import assert from 'node:assert/strict'; -import test from 'node:test'; -import { afterEach } from 'node:test'; +import { afterEach, test } from 'node:test'; import { act, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { parseHTML } from 'linkedom'; @@ -54,7 +51,7 @@ afterEach(async () => { }); }); -function domRoot() { +async function renderIdentity(sessionName: string, readOnly = false) { const { document, window } = parseHTML('
'); window.getComputedStyle = () => new Proxy( @@ -69,24 +66,16 @@ function domRoot() { matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), IS_REACT_ACT_ENVIRONMENT: true, }); - const container = document.querySelector('#root'); - assert.ok(container); + const container = document.querySelector('#root') as HTMLElement; const root = createRoot(container); mountedRoots.push(root); - return { container, root } as { - container: HTMLElement; - root: ReturnType; - }; -} - -async function renderIdentity(sessionName: string) { - const { container, root } = domRoot(); await act(() => { root.render( undefined} project={{ name: 'p' }} /> @@ -94,26 +83,10 @@ async function renderIdentity(sessionName: string) { , ); }); - return container; + return container.querySelector('.maka-titlebar-identity__segment--session'); } -test('an untruncated session name keeps the rename hint configured on the chip', async () => { - const container = await renderIdentity('Short name'); - const nameButton = container.querySelector('.maka-titlebar-identity__name'); - assert.ok(nameButton); - assert.equal(nameButton.getAttribute('aria-label'), 'Short name — Rename task'); - // Tooltip is always configured (rename hint when visible, full name when - // truncated); only its content switches. aria-describedby proves it is - // attached; the visible content is the story plays' job. - const nameSpan = container.querySelector('.maka-titlebar-identity__segment--session'); - assert.ok(nameSpan); - assert.notEqual(nameButton.getAttribute('aria-describedby'), null); - // The read-only branch's native title stays truncation-gated. - const readOnly = await renderReadOnly('Short name'); - assert.equal(readOnly.getAttribute('title'), null); -}); - -test('every branch binds the rendered text node for measurement, including read-only', async () => { +test('both branches hand their rendered span to the truncation measurement', async () => { const observed: unknown[] = []; const originalObserver = globalThis.ResizeObserver; class RecordingObserver { @@ -123,39 +96,11 @@ test('every branch binds the rendered text node for measurement, including read- } globalThis.ResizeObserver = RecordingObserver as unknown as typeof ResizeObserver; try { - const container = await renderIdentity('Short name'); - const editableSpan = container.querySelector('.maka-titlebar-identity__segment--session'); - assert.ok(editableSpan); - assert.ok(observed.includes(editableSpan), 'editable branch must observe its rendered span'); - observed.length = 0; - const readOnlySpan = await renderReadOnly('A long shared-session title that would not fit the chip width at all'); - // Regression: the read-only branch once rendered its span without the - // measure ref, so `clipped` stayed false forever and the native title - // disappeared from shared sessions. - assert.ok(observed.includes(readOnlySpan), 'read-only branch must bind the measure ref'); + const editableSpan = await renderIdentity('Short name'); + assert.ok(observed.includes(editableSpan)); + const readOnlySpan = await renderIdentity('Another long shared-session title that overflows the chip', true); + assert.ok(observed.includes(readOnlySpan)); } finally { globalThis.ResizeObserver = originalObserver; } }); - -async function renderReadOnly(sessionName: string) { - const { container, root } = domRoot(); - await act(() => { - root.render( - - - undefined} readOnly /> - - , - ); - }); - return container.querySelector('.maka-titlebar-identity__segment--session')!; -} -test('the task-actions button never repeats the session name in its tooltip', async () => { - const longName = ' brew — a long task title about Homebrew updates that overflows the titlebar chip '; - const container = await renderIdentity(longName); - const menuButton = container.querySelector('[aria-label$=" — Task actions"]'); - assert.ok(menuButton); - assert.equal(menuButton.getAttribute('aria-label'), `${longName} — Task actions`); - assert.equal(menuButton.getAttribute('title'), null); -}); diff --git a/packages/ui/src/titlebar-session-identity.tsx b/packages/ui/src/titlebar-session-identity.tsx index 431191c392..1ee41eac01 100644 --- a/packages/ui/src/titlebar-session-identity.tsx +++ b/packages/ui/src/titlebar-session-identity.tsx @@ -38,23 +38,15 @@ export interface TitlebarParentSession { onOpen(): void; } -/** - * Whether an ellipsis-clipped label is actually cut off. Astryx Button wraps - * children in an internal label span, so the measurement must happen on the - * text node itself (same recipe as quote-ref-chip). The ellipsis styles live - * in the app shell (`maka-titlebar-identity__segment--session`). - */ +/** Astryx Button wraps children in an internal label span, so the ellipsis + * measurement must happen on the text node itself; the ellipsis CSS lives in + * the app shell (`maka-titlebar-identity__segment--session`). */ function isNameClipped(el: HTMLElement | null): boolean { return !!el && el.scrollWidth > el.clientWidth + 1; } -/** - * Track clipping across resize/layout changes so the tooltip appears exactly - * when the visible name is truncated — and never repeats a fully visible one. - * The observed node is whichever span the current branch rendered; renaming - * swaps the span for an input and back, so the render phase must rebind on - * every such swap (`inactive`), not only when the name text changes. - */ +/** Re-measure on every branch swap of the measured span, not just name + * changes: entering/leaving rename replaces the span and strands the observer. */ function useNameClipped(sessionName: string, inactive: boolean) { const measureRef = useRef(null); const [clipped, setClipped] = useState(false); @@ -98,9 +90,8 @@ export function TitlebarSessionIdentity(props: { const path = props.project?.path; const { measureRef, clipped } = useNameClipped(props.sessionName, renaming); - // One control, two kinds of hidden information: with the name truncated, - // what the reader cannot see is the full text; fully visible, the missing - // piece is the click affordance — rename. Never the name only. + // Two kinds of hidden information: truncated → the full unseen text; + // visible → the rename affordance the surface never advertises. const nameTooltip = clipped ? props.sessionName : copy.sessions.renameAriaLabel; const copyPhase = path ? clipboard.phaseFor(path) : null; const copyLabel = copyPhase === 'pending' ? copy.messages.copying