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); }); });