diff --git a/.changeset/chat-title-header.md b/.changeset/chat-title-header.md
new file mode 100644
index 000000000..767963d50
--- /dev/null
+++ b/.changeset/chat-title-header.md
@@ -0,0 +1,5 @@
+---
+'@truefoundry/trueforge-ui': patch
+---
+
+Show the current chat title persistently above conversations in every layout.
diff --git a/packages/trueforge-ui/src/atoms/ChatTitleHeaderLabel.tsx b/packages/trueforge-ui/src/atoms/ChatTitleHeaderLabel.tsx
new file mode 100644
index 000000000..000ad2318
--- /dev/null
+++ b/packages/trueforge-ui/src/atoms/ChatTitleHeaderLabel.tsx
@@ -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;
+ });
+ const displayTitle = title?.trim() || UNTITLED_CHAT_LABEL;
+
+ return (
+
+ {displayTitle}
+
+ );
+}
diff --git a/packages/trueforge-ui/src/atoms/NamedAgentHeaderLabel.tsx b/packages/trueforge-ui/src/atoms/NamedAgentHeaderLabel.tsx
index 4607a07d0..f22574d40 100644
--- a/packages/trueforge-ui/src/atoms/NamedAgentHeaderLabel.tsx
+++ b/packages/trueforge-ui/src/atoms/NamedAgentHeaderLabel.tsx
@@ -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 (
-
+
{state.name}
@@ -24,6 +27,6 @@ export function NamedAgentHeaderLabel({ className }: { className?: string }) {
) : null}
-
+
);
}
diff --git a/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts b/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts
index b92c711ef..3ddee7e6c 100644
--- a/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts
+++ b/packages/trueforge-ui/src/hooks/useChatChromeActionsVisible.ts
@@ -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();
@@ -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;
}
diff --git a/packages/trueforge-ui/src/layouts/DrawerLayout.tsx b/packages/trueforge-ui/src/layouts/DrawerLayout.tsx
index caed58cd6..de3b920fe 100644
--- a/packages/trueforge-ui/src/layouts/DrawerLayout.tsx
+++ b/packages/trueforge-ui/src/layouts/DrawerLayout.tsx
@@ -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';
@@ -70,8 +71,8 @@ export function DrawerLayout({ className }: { className?: string }) {
{!settingsOpen ? (
<>
-
-
+
+
>
diff --git a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx
index 21511cfb1..1a2e63dfb 100644
--- a/packages/trueforge-ui/src/layouts/SidebarLayout.tsx
+++ b/packages/trueforge-ui/src/layouts/SidebarLayout.tsx
@@ -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';
@@ -160,8 +161,8 @@ export function SidebarLayout({ className }: { className?: string }) {
>
-
-
+
+
>
diff --git a/packages/trueforge-ui/src/layouts/StackChatPanel.tsx b/packages/trueforge-ui/src/layouts/StackChatPanel.tsx
index e84396494..58f035efb 100644
--- a/packages/trueforge-ui/src/layouts/StackChatPanel.tsx
+++ b/packages/trueforge-ui/src/layouts/StackChatPanel.tsx
@@ -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';
@@ -75,8 +76,8 @@ export function StackChatPanel({ className, threadHeaderEnd }: StackChatPanelPro
>
-
-
+
+
{threadHeaderEnd}
diff --git a/packages/trueforge-ui/test/atoms/ChatTitleHeaderLabel.test.tsx b/packages/trueforge-ui/test/atoms/ChatTitleHeaderLabel.test.tsx
new file mode 100644
index 000000000..69dbebee8
--- /dev/null
+++ b/packages/trueforge-ui/test/atoms/ChatTitleHeaderLabel.test.tsx
@@ -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();
+
+ 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();
+
+ expect(screen.getByRole('heading', { name: title })).toHaveClass('min-w-0', 'flex-1', 'truncate');
+ expect(screen.getByRole('heading', { name: title })).toHaveAttribute('title', title);
+ });
+});
diff --git a/packages/trueforge-ui/test/atoms/NamedAgentHeaderLabel.test.tsx b/packages/trueforge-ui/test/atoms/NamedAgentHeaderLabel.test.tsx
index d4ff3895c..58a118a69 100644
--- a/packages/trueforge-ui/test/atoms/NamedAgentHeaderLabel.test.tsx
+++ b/packages/trueforge-ui/test/atoms/NamedAgentHeaderLabel.test.tsx
@@ -40,16 +40,16 @@ describe('NamedAgentHeaderLabel', () => {
,
);
- expect(screen.getByRole('heading', { name: 'support' })).toBeInTheDocument();
+ expect(screen.getByText('support')).toBeInTheDocument();
});
it('is hidden for unnamed draft chats', () => {
- render(
+ const { container } = render(
,
);
- 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', () => {
@@ -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', () => {
@@ -74,11 +75,11 @@ describe('NamedAgentHeaderLabel', () => {
,
);
- 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();
});
});
diff --git a/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx b/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx
index d9a5e8085..4a8c5ec78 100644
--- a/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx
+++ b/packages/trueforge-ui/test/hooks/useChatChromeActionsVisible.test.tsx
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest';
import {
useChatChromeActionsVisible,
useChatHeaderContentVisible,
+ useChatTitleHeaderVisible,
useNamedAgentHeaderVisible,
useSaveAgentVisible,
} from '@/hooks/useChatChromeActionsVisible.js';
@@ -44,6 +45,7 @@ describe('useChatHeaderContentVisible', () => {
return {
shell,
named: useNamedAgentHeaderVisible(),
+ title: useChatTitleHeaderVisible(),
save: useSaveAgentVisible(),
clear: useChatChromeActionsVisible(),
header: useChatHeaderContentVisible(),
@@ -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);
@@ -66,6 +69,7 @@ describe('useChatHeaderContentVisible', () => {
const { result } = renderHook(
() => ({
named: useNamedAgentHeaderVisible(),
+ title: useChatTitleHeaderVisible(),
save: useSaveAgentVisible(),
clear: useChatChromeActionsVisible(),
header: useChatHeaderContentVisible(),
@@ -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);
});
});