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..795babec2b 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1790,6 +1790,17 @@ 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, 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: '检查项目菜单 — 重命名任务' }))); + 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'); @@ -1833,16 +1844,29 @@ 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} — 重命名任务`); + await userEvent.hover(rename); + await waitFor(() => expect(page.getByRole('tooltip', { name: full })).toBeVisible()); + 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..d676e6d6f6 --- /dev/null +++ b/packages/ui/src/__tests__/titlebar-session-identity.test.tsx @@ -0,0 +1,106 @@ +/* + * 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. + */ + +/** + * 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 { afterEach, test } 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, + }); +}); + +async function renderIdentity(sessionName: string, readOnly = false) { + 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') as HTMLElement; + const root = createRoot(container); + mountedRoots.push(root); + await act(() => { + root.render( + + + undefined} + project={{ name: 'p' }} + /> + + , + ); + }); + return container.querySelector('.maka-titlebar-identity__segment--session'); +} + +test('both branches hand their rendered span to the truncation measurement', 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 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; + } +}); diff --git a/packages/ui/src/titlebar-session-identity.tsx b/packages/ui/src/titlebar-session-identity.tsx index 126d837a7c..1ee41eac01 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,29 @@ export interface TitlebarParentSession { onOpen(): void; } +/** 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; +} + +/** 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); + useLayoutEffect(() => { + const el = measureRef.current; + setClipped(isNameClipped(el)); + if (!el || inactive || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => setClipped(isNameClipped(el))); + observer.observe(el); + return () => observer.disconnect(); + }, [sessionName, inactive]); + return { measureRef, clipped }; +} + export function TitlebarSessionIdentity(props: { sessionName: string; onRenameSession(name: string): void; @@ -66,6 +89,10 @@ export function TitlebarSessionIdentity(props: { }, [renaming]); const path = props.project?.path; + const { measureRef, clipped } = useNameClipped(props.sessionName, renaming); + // 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 : copyPhase === 'failed' ? copy.messages.copyFailed @@ -128,7 +155,7 @@ export function TitlebarSessionIdentity(props: { onCancel={() => endRename(true)} /> ) : props.readOnly ? ( - + {props.sessionName} ) : ( @@ -136,19 +163,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}