mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
feat: Improve chat title and copy button layout on Windows (#1202)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user