feat: Improve chat title and copy button layout on Windows (#1202)

This commit is contained in:
paisley
2026-07-29 15:48:19 +08:00
committed by GitHub
parent 82b7f445af
commit 2a4426d4ff
7 changed files with 119 additions and 20 deletions
+25
View File
@@ -2,6 +2,14 @@ const ACP_WORKING_DIRECTORY_PREFIX = /^\[Working directory: [^\r\n]*\](?:\r?\n){
const ACP_WORKING_DIRECTORY_TRUNCATED_TITLE = /^\[Working directory: [^\r\n]*\]…$/
const OPENCLAW_SESSION_ID_FALLBACK_TITLE = /^([0-9a-f]{8}) \((\d{4}-\d{2}-\d{2})\)$/i
export type SessionTitleSource = {
key: string
sessionId?: string
label?: string
derivedTitle?: string
displayName?: string
}
export function stripAcpWorkingDirectoryPrefix(text: string): string {
return text.replace(ACP_WORKING_DIRECTORY_PREFIX, '')
}
@@ -19,3 +27,20 @@ export function isOpenClawSessionIdFallbackTitle(
const match = text.trim().match(OPENCLAW_SESSION_ID_FALLBACK_TITLE)
return Boolean(match && normalizedSessionId.startsWith(match[1]!.toLowerCase()))
}
/** Resolve the same human-readable title used for a session everywhere in the UI. */
export function getSessionDisplayTitle(
session: SessionTitleSource,
sessionLabels: Record<string, string> = {},
): string {
const candidates = [
sessionLabels[session.key],
session.label,
session.derivedTitle,
session.displayName,
]
return candidates.find((candidate) => (
candidate?.trim()
&& !isOpenClawSessionIdFallbackTitle(candidate, session.sessionId)
))?.trim() ?? session.key
}
+3 -16
View File
@@ -34,7 +34,7 @@ import { cn } from '@/lib/utils';
import { isGatewayRestarting } from '@/lib/gateway-status';
import { rendererExtensionRegistry } from '@/extensions/registry';
import { useSettingsStore } from '@/stores/settings';
import { useChatStore, type ChatSession } from '@/stores/chat';
import { useChatStore } from '@/stores/chat';
import { useSessionAttentionStore } from '@/stores/session-attention';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
@@ -54,7 +54,7 @@ import { useNewChatAction } from './use-new-chat-action';
import { isDefaultWorkspacePath } from '@/lib/workspace-context';
import { useWorkspaceAvailability } from '@/hooks/use-workspace-availability';
import { projectSessionRunState } from '@/stores/chat/session-status';
import { isOpenClawSessionIdFallbackTitle } from '@shared/chat/session-title';
import { getSessionDisplayTitle } from '@shared/chat/session-title';
interface NavItemProps {
to: string;
@@ -202,19 +202,6 @@ export function Sidebar() {
const navigate = useNavigate();
const isOnChat = useLocation().pathname === '/';
const getSessionLabel = (session: ChatSession) => {
const candidates = [
sessionLabels[session.key],
session.label,
session.derivedTitle,
session.displayName,
];
return candidates.find((candidate) => (
candidate?.trim()
&& !isOpenClawSessionIdFallbackTitle(candidate, session.sessionId)
))?.trim() ?? session.key;
};
const openControlUi = async (view?: 'dreams', label = 'OpenClaw Page') => {
try {
const result = await hostApi.gateway.controlUi(view);
@@ -726,7 +713,7 @@ export function Sidebar() {
const agentName = agentNameById[agentId] || agentId;
const isEditing = editingSessionKey === s.key;
const isCurrentSession = isOnChat && currentSessionKey === s.key;
const sessionLabel = getSessionLabel(s);
const sessionLabel = getSessionDisplayTitle(s, sessionLabels);
const relativeTime = formatSessionRelativeTime(activityMs, nowMs, i18n.language);
const runState = projectSessionRunState(s);
const attention = sessionAttentionByKey[s.key];
+1 -1
View File
@@ -126,7 +126,7 @@ export function AcpAssistantHoverBar({ text }: { text: string }) {
const label = copied ? t('acp.copied') : t('acp.copy');
return (
<div className="flex w-full justify-end px-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100">
<div className="flex w-full justify-start px-1 opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100">
<button
type="button"
data-testid="acp-assistant-copy"
+20 -1
View File
@@ -28,6 +28,7 @@ import { getAcpUserMessageAnchorId } from '@/lib/acp/timeline-anchors';
import type { MessageSegmentItem, RenderPart } from '@/lib/acp/timeline-types';
import { projectOpenClawFileActivities, type AcpFileActivityProjection } from '@/lib/acp/openclaw-file-activities';
import { hostApi } from '@/lib/host-api';
import { getSessionDisplayTitle } from '@shared/chat/session-title';
import { ChatInput, type ChatWorkspaceOption, type FileAttachment } from './ChatInput';
import { ChatToolbar } from './ChatToolbar';
import { AcpTimeline } from './AcpTimeline';
@@ -179,6 +180,7 @@ export function Chat() {
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
const sessions = useChatStore((s) => s.sessions);
const sessionLabels = useChatStore((s) => s.sessionLabels);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const loadSessions = useChatStore((s) => s.loadSessions);
const selectAcpSession = useChatStore((s) => s.selectAcpSession);
@@ -204,6 +206,9 @@ export function Chat() {
() => sessions.find((session) => session.key === currentSessionKey) ?? null,
[currentSessionKey, sessions],
);
const currentSessionTitle = currentSession
? getSessionDisplayTitle(currentSession, sessionLabels)
: currentSessionKey;
const effectiveWorkspace = useMemo(
() => resolveEffectiveWorkspace({ session: currentSession, globalWorkspace: chatWorkspacePath }),
[chatWorkspacePath, currentSession],
@@ -445,8 +450,22 @@ export function Chat() {
style={{ height: isMac ? 'calc(100vh - 1px)' : 'calc(100vh - 2.5rem)' }}
>
<div className="flex min-w-0 flex-1 flex-col">
<div className="relative flex shrink-0 items-center justify-end px-4 py-2">
<div className={cn(
'relative flex shrink-0 items-center px-4 py-2',
isWindows ? 'gap-4' : 'justify-end',
)}>
<div data-testid="chat-toolbar-drag-region" className="drag-region absolute inset-0 z-0" aria-hidden="true" />
{isWindows && (
<div className="drag-region relative z-10 min-w-0 flex-1">
<h1
data-testid="chat-session-title"
title={currentSessionTitle}
className="truncate text-sm font-medium text-foreground"
>
{currentSessionTitle}
</h1>
</div>
)}
<div data-testid="chat-toolbar-actions" className="no-drag relative z-10">
<ChatToolbar
questionDirectoryOpen={questionDirectoryVisible}
+10 -2
View File
@@ -596,9 +596,17 @@ test.describe('ClawX ACP inline timeline', () => {
await expect(page.getByTestId('acp-assistant-avatar')).toBeVisible();
await assistantMessage.hover();
await page.getByTestId('acp-assistant-copy').click();
const copyButton = page.getByTestId('acp-assistant-copy');
await expect.poll(async () => {
const [assistantBox, copyBox] = await Promise.all([
assistantMessage.boundingBox(),
copyButton.boundingBox(),
]);
return !!assistantBox && !!copyBox && copyBox.x <= assistantBox.x + 8;
}).toBe(true);
await copyButton.click();
await expect(page.getByTestId('acp-assistant-copy')).toHaveAttribute('aria-label', 'Copied');
await expect(copyButton).toHaveAttribute('aria-label', 'Copied');
await expect.poll(() => page.evaluate(() => (window as unknown as { __acpCopiedText?: string }).__acpCopiedText)).toBe('Copy this ACP answer');
} finally {
await closeElectronApp(app);
+33
View File
@@ -223,6 +223,7 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
[stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: DEFAULT_WORKSPACE, cwd: DEFAULT_WORKSPACE }])]: acpLoadResult,
[stableStringify(['chat', 'loadAcpSession', { sessionKey: SESSION_KEY, workspaceRoot: SESSION_WORKSPACE, cwd: SESSION_WORKSPACE }])]: acpLoadResult,
[stableStringify(['sessions', 'delete', { id: SESSION_KEY }])]: { success: true },
[stableStringify(['sessions', 'rename', { id: SESSION_KEY, title: 'Renamed conversation' }])]: { success: true },
},
recordHostInvocations: true,
});
@@ -347,6 +348,38 @@ test.describe('ClawX chat workspace context', () => {
}
});
test('chat header shows the current sidebar title and follows session renames', async ({ launchElectronApp }) => {
test.skip(process.platform !== 'win32', 'Conversation title header is Windows-only');
const app = await launchElectronApp({ skipSetup: true });
try {
await installWorkspaceMocks(app, {
sessionDerivedTitle: AUTO_TITLE_WITH_CWD,
sessionSummaryFirstUserText: null,
});
const page = await getStableWindow(app);
try {
await page.reload();
} catch (error) {
if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error;
}
const chatTitle = page.getByTestId('chat-session-title');
await expect(chatTitle).toHaveText('Workspace chat', { timeout: 30_000 });
const sidebarSession = page.getByTestId(`sidebar-session-${SESSION_KEY}`);
await sidebarSession.hover();
await page.getByRole('button', { name: 'Rename' }).click();
await page.getByRole('textbox', { name: 'Session title' }).fill('Renamed conversation');
await page.getByRole('button', { name: 'Save session title' }).click();
await expect(chatTitle).toHaveText('Renamed conversation');
} finally {
await closeElectronApp(app);
}
});
test('new chat workspace menu lists and switches to recent and known workspaces', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
+27
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
getSessionDisplayTitle,
isAcpWorkingDirectoryTruncatedTitle,
isOpenClawSessionIdFallbackTitle,
stripAcpWorkingDirectoryPrefix,
@@ -67,6 +68,32 @@ describe('isOpenClawSessionIdFallbackTitle', () => {
})
})
describe('getSessionDisplayTitle', () => {
const session = {
key: 'agent:main:session-a',
sessionId: '72e4b28b-8477-4e29-b57e-e14448fd42d0',
label: 'Generated title',
derivedTitle: 'Derived title',
displayName: 'Display name',
}
it('prefers the persisted user label shared by the sidebar and chat header', () => {
expect(getSessionDisplayTitle(session, { [session.key]: 'Renamed conversation' }))
.toBe('Renamed conversation')
})
it('skips an OpenClaw UUID fallback title', () => {
expect(getSessionDisplayTitle({
...session,
label: '72e4b28b (2026-07-22)',
}, {})).toBe('Derived title')
})
it('falls back safely when session labels are unavailable', () => {
expect(getSessionDisplayTitle(session)).toBe('Generated title')
})
})
describe('isAcpWorkingDirectoryTruncatedTitle', () => {
it('identifies a cwd envelope truncated before the user prompt', () => {
expect(