Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/chat-title-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@truefoundry/trueforge-ui': patch
---

Show the current chat title persistently above conversations in every layout.
25 changes: 25 additions & 0 deletions packages/trueforge-ui/src/atoms/ChatTitleHeaderLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
'use client';

import { useAuiState } from '../assistant-ui.js';
import { cn } from './lib/cn.js';

const UNTITLED_CHAT_LABEL = 'New Chat';

/** Current chat title shown persistently above the conversation. */
export function ChatTitleHeaderLabel({ className }: { className?: string }) {
const title = useAuiState(state => {
const { mainThreadId, threadItems } = state.threads;
return threadItems.find(item => item.id === mainThreadId)?.title;
});
Comment thread
theyashdedhia marked this conversation as resolved.
const displayTitle = title?.trim() || UNTITLED_CHAT_LABEL;
Comment thread
cursor[bot] marked this conversation as resolved.

return (
<h1
data-slot="aui_chat-title"
className={cn('min-w-0 flex-1 truncate px-1 text-sm font-medium text-text-primary', className)}
title={displayTitle}
>
{displayTitle}
</h1>
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title shown while shell idle

Medium Severity

ChatTitleHeaderLabel always renders, but useChatTitleHeaderVisible is only true when the shell is active. Sibling chrome (NamedAgentHeaderLabel, ClearChatButton, SaveAgentButton) self-hides when inactive. On Agent Library idle screens the header still shows New Chat (or a leftover thread title) beside “Select an agent…”, which mislabels the active context. Layouts also dropped the old flex-1 spacer and rely on this label’s flex-1, so the label stays mounted for spacing even when it should not be visible.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b7ba101. Configure here.

}
9 changes: 6 additions & 3 deletions packages/trueforge-ui/src/atoms/NamedAgentHeaderLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { Icon } from '../icons/Icon.js';
import { cn } from './lib/cn.js';
import { Tooltip } from './primitives/Tooltip.js';

/** Left-of-header title for a named agent, including its mutable edit state. */
/** Context label for the named agent associated with the current chat. */
export function NamedAgentHeaderLabel({ className }: { className?: string }) {
const state = useNamedAgentHeaderState();
if (state === null) return null;

return (
<h1 className={cn('flex min-w-0 items-center gap-1.5 px-1 text-sm font-medium text-text-primary', className)}>
<div
data-slot="aui_named-agent-label"
className={cn('flex min-w-0 items-center gap-1.5 px-1 text-sm font-medium text-text-primary', className)}
>
<Icon name="robot" className="size-3.5 shrink-0" />
<span className="truncate" title={state.name}>
{state.name}
Expand All @@ -24,6 +27,6 @@ export function NamedAgentHeaderLabel({ className }: { className?: string }) {
</span>
</Tooltip>
) : null}
</h1>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export function useNamedAgentHeaderVisible(): boolean {
return state !== null;
}

export function useChatTitleHeaderVisible(): boolean {
const shell = useOptionalShellMode();
return shell?.mode.status === 'active';
}

// Mutable draft/edit with a selected model — drives Save Agent + header chrome.
export function useSaveAgentVisible(): boolean {
const shell = useOptionalShellMode();
Expand All @@ -40,8 +45,9 @@ export function useChatChromeActionsVisible(): boolean {
// True when the thread header has anything to show (title, Save, and/or Clear).
// Clear alone matters for orphaned immutable history (deleted agent, no name).
export function useChatHeaderContentVisible(): boolean {
const titleVisible = useChatTitleHeaderVisible();
const namedVisible = useNamedAgentHeaderVisible();
const saveVisible = useSaveAgentVisible();
const clearVisible = useChatChromeActionsVisible();
return namedVisible || saveVisible || clearVisible;
return titleVisible || namedVisible || saveVisible || clearVisible;
}
5 changes: 3 additions & 2 deletions packages/trueforge-ui/src/layouts/DrawerLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react';

import { useAui } from '../assistant-ui.js';
import { ChatTitleHeaderLabel } from '../atoms/ChatTitleHeaderLabel.js';
import { NamedAgentHeaderLabel } from '../atoms/NamedAgentHeaderLabel.js';
import { ShellActions } from '../atoms/ShellActions.js';
import { auiButtonClass } from '../atoms/lib/buttonClasses.js';
Expand Down Expand Up @@ -70,8 +71,8 @@ export function DrawerLayout({ className }: { className?: string }) {
<header className="flex shrink-0 items-center gap-1 border-b border-border bg-topbar-bg px-2 py-1.5">
{!settingsOpen ? (
<>
<NamedAgentHeaderLabel />
<span className="min-w-0 flex-1" />
<ChatTitleHeaderLabel />
<NamedAgentHeaderLabel className="max-w-[40%] shrink-0" />
<ClearChatButton />
<SaveAgentButton />
</>
Expand Down
5 changes: 3 additions & 2 deletions packages/trueforge-ui/src/layouts/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react';

import { useAui } from '../assistant-ui.js';
import { ChatTitleHeaderLabel } from '../atoms/ChatTitleHeaderLabel.js';
import { auiButtonClass } from '../atoms/lib/buttonClasses.js';
import { cn } from '../atoms/lib/cn.js';
import { NamedAgentHeaderLabel } from '../atoms/NamedAgentHeaderLabel.js';
Expand Down Expand Up @@ -160,8 +161,8 @@ export function SidebarLayout({ className }: { className?: string }) {
>
<Icon name="bars" />
</button>
<NamedAgentHeaderLabel />
<span className="min-w-0 flex-1" />
<ChatTitleHeaderLabel />
<NamedAgentHeaderLabel className="max-w-[40%] shrink-0" />
<ClearChatButton />
<SaveAgentButton />
</>
Expand Down
5 changes: 3 additions & 2 deletions packages/trueforge-ui/src/layouts/StackChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { lazy, Suspense, useEffect, useState, type ReactNode } from 'react';

import { useAui } from '../assistant-ui.js';
import { ChatTitleHeaderLabel } from '../atoms/ChatTitleHeaderLabel.js';
import { NamedAgentHeaderLabel } from '../atoms/NamedAgentHeaderLabel.js';
import { ShellActions } from '../atoms/ShellActions.js';
import { auiButtonClass } from '../atoms/lib/buttonClasses.js';
Expand Down Expand Up @@ -75,8 +76,8 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro
>
<Icon name="clock-rotate-left" />
</button>
<NamedAgentHeaderLabel />
<span className="min-w-0 flex-1" />
<ChatTitleHeaderLabel />
<NamedAgentHeaderLabel className="max-w-[40%] shrink-0" />
<ClearChatButton />
<SaveAgentButton />
{threadHeaderEnd}
Expand Down
45 changes: 45 additions & 0 deletions packages/trueforge-ui/test/atoms/ChatTitleHeaderLabel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

const useAuiState = vi.fn();

vi.mock('@/assistant-ui.js', () => ({
useAuiState: (selector: (state: unknown) => unknown) => useAuiState(selector),
}));

import { ChatTitleHeaderLabel } from '@/atoms/ChatTitleHeaderLabel.js';

describe('ChatTitleHeaderLabel', () => {
beforeEach(() => {
useAuiState.mockImplementation((selector: (state: unknown) => unknown) =>
selector({ threads: { mainThreadId: 'main', threadItems: [{ id: 'main', title: undefined }] } }),
);
});

it('shows a placeholder for an untitled chat', () => {
render(<ChatTitleHeaderLabel />);

expect(screen.getByRole('heading', { name: 'New Chat' })).toBeInTheDocument();
});

it('shows the current title with truncation and a full-title tooltip', () => {
const title = 'A very long conversation title that should stay on one line';
useAuiState.mockImplementation((selector: (state: unknown) => unknown) =>
selector({
threads: {
mainThreadId: 'active-thread',
threadItems: [
{ id: 'other-thread', title: 'Another conversation' },
{ id: 'active-thread', title },
],
},
}),
);

render(<ChatTitleHeaderLabel />);

expect(screen.getByRole('heading', { name: title })).toHaveClass('min-w-0', 'flex-1', 'truncate');
expect(screen.getByRole('heading', { name: title })).toHaveAttribute('title', title);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,16 @@ describe('NamedAgentHeaderLabel', () => {
<NamedAgentHeaderLabel />
</ShellModeProvider>,
);
expect(screen.getByRole('heading', { name: 'support' })).toBeInTheDocument();
expect(screen.getByText('support')).toBeInTheDocument();
});

it('is hidden for unnamed draft chats', () => {
render(
const { container } = render(
<ShellModeProvider agentConfig={{ mode: 'AgentComposer' }}>
<NamedAgentHeaderLabel />
</ShellModeProvider>,
);
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect(container.querySelector('[data-slot="aui_named-agent-label"]')).not.toBeInTheDocument();
});

it('shows an Editing label for a saved agent opened in mutable mode', () => {
Expand All @@ -64,7 +64,8 @@ describe('NamedAgentHeaderLabel', () => {
screen.getByRole('button', { name: 'edit' }).click();
});

expect(screen.getByRole('heading', { name: 'reviewer Editing' })).toBeInTheDocument();
expect(screen.getByText('reviewer')).toBeInTheDocument();
expect(screen.getByText('Editing')).toBeInTheDocument();
});

it('is hidden while idle, then appears after selecting a named agent', () => {
Expand All @@ -74,11 +75,11 @@ describe('NamedAgentHeaderLabel', () => {
<NamedAgentHeaderLabel />
</ShellModeProvider>,
);
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
expect(screen.queryByText('reviewer')).not.toBeInTheDocument();

act(() => {
screen.getByRole('button', { name: 'select' }).click();
});
expect(screen.getByRole('heading', { name: 'reviewer' })).toBeInTheDocument();
expect(screen.getByText('reviewer')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
useChatChromeActionsVisible,
useChatHeaderContentVisible,
useChatTitleHeaderVisible,
useNamedAgentHeaderVisible,
useSaveAgentVisible,
} from '@/hooks/useChatChromeActionsVisible.js';
Expand Down Expand Up @@ -44,6 +45,7 @@ describe('useChatHeaderContentVisible', () => {
return {
shell,
named: useNamedAgentHeaderVisible(),
title: useChatTitleHeaderVisible(),
save: useSaveAgentVisible(),
clear: useChatChromeActionsVisible(),
header: useChatHeaderContentVisible(),
Expand All @@ -57,6 +59,7 @@ describe('useChatHeaderContentVisible', () => {
});

expect(result.current.named).toBe(false);
expect(result.current.title).toBe(true);
expect(result.current.save).toBe(false);
expect(result.current.clear).toBe(true);
expect(result.current.header).toBe(true);
Expand All @@ -66,6 +69,7 @@ describe('useChatHeaderContentVisible', () => {
const { result } = renderHook(
() => ({
named: useNamedAgentHeaderVisible(),
title: useChatTitleHeaderVisible(),
save: useSaveAgentVisible(),
clear: useChatChromeActionsVisible(),
header: useChatHeaderContentVisible(),
Expand All @@ -78,6 +82,8 @@ describe('useChatHeaderContentVisible', () => {
);

expect(result.current.named).toBe(false);
expect(result.current.title).toBe(true);
expect(result.current.clear).toBe(false);
expect(result.current.header).toBe(true);
});
});