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}