Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/styles/shell-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 26 additions & 2 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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: () => (
<ComposedShell
sidebarCollapsed
session={{
name: 'Chat Surface 会话上下文在极窄窗口中的响应式收敛与信息优先级验证',
name: 'Chat Surface 会话上下文在极窄窗口中的响应式收敛与信息优先级验证'.repeat(3),
}}
/>
),
play: async ({ canvasElement }) => {
const full = 'Chat Surface 会话上下文在极窄窗口中的响应式收敛与信息优先级验证'.repeat(3);
const page = within(canvasElement.ownerDocument.body);
const rename = canvasElement.querySelector<HTMLElement>('.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<HTMLElement>('[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
Expand Down
106 changes: 106 additions & 0 deletions packages/ui/src/__tests__/titlebar-session-identity.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof createRoot>[] = [];

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('<div id="root"></div>');
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(
<StrictMode>
<LocaleProvider locale="en">
<TitlebarSessionIdentity
sessionName={sessionName}
readOnly={readOnly}
onRenameSession={() => undefined}
project={{ name: 'p' }}
/>
</LocaleProvider>
</StrictMode>,
);
});
return container.querySelector<HTMLElement>('.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;
}
});
37 changes: 32 additions & 5 deletions packages/ui/src/titlebar-session-identity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<HTMLSpanElement>(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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -128,27 +155,27 @@ export function TitlebarSessionIdentity(props: {
onCancel={() => endRename(true)}
/>
) : props.readOnly ? (
<span className="maka-titlebar-identity__name maka-titlebar-identity__segment--session" title={props.sessionName}>
<span ref={measureRef} className="maka-titlebar-identity__name maka-titlebar-identity__segment--session" title={clipped ? props.sessionName : undefined}>
{props.sessionName}
</span>
) : (
<Button
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)}
>
<span className="maka-titlebar-identity__segment--session">{props.sessionName}</span>
<span ref={measureRef} className="maka-titlebar-identity__segment--session">{props.sessionName}</span>
</Button>
)}
{!props.readOnly || props.action || (props.parentSession && props.project) ? (
<span className="maka-titlebar-identity__action">
<DropdownMenu
className="maka-titlebar-menu"
button={{ label: `${props.sessionName} — ${copy.chat.taskActions}`, tooltip: `${props.sessionName} — ${copy.chat.taskActions}`, icon: <MoreHorizontal size={14} />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
button={{ label: `${props.sessionName} — ${copy.chat.taskActions}`, tooltip: copy.chat.taskActions, icon: <MoreHorizontal size={14} />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
hasChevron={false}
alignment="end"
isMenuOpen={actionsMenuOpen}
Expand Down