Skip to content

Commit 5cb396e

Browse files
authored
👋 feat: Optional Greeting Heading and Footer-Free Welcome Screen (#17055)
1 parent b7d096f commit 5cb396e

21 files changed

Lines changed: 438 additions & 33 deletions

File tree

‎api/server/experimental.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,7 @@ if (cluster.isMaster) {
543543
so the answer is the deployment's base configuration, like index.js. */
544544
indexHTML = injectConfiguredFooterBootstrap(indexHTML, {
545545
customFooter: process.env.CUSTOM_FOOTER,
546+
landingFooter: baseAppConfig?.interfaceConfig?.landingFooter,
546547
});
547548

548549
indexHTML = injectColorModeHandoff(indexHTML, baseAppConfig?.interfaceConfig?.colorModeFromUrl);

‎api/server/index.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ const startServer = async () => {
301301
caller's and the client prefers it. */
302302
indexHTML = injectConfiguredFooterBootstrap(indexHTML, {
303303
customFooter: process.env.CUSTOM_FOOTER,
304+
landingFooter: appConfig?.interfaceConfig?.landingFooter,
304305
});
305306

306307
indexHTML = injectColorModeHandoff(indexHTML, appConfig.interfaceConfig?.colorModeFromUrl);

‎client/src/common/types.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,9 @@ declare global {
692692
/** Whether this deployment configured footer content of its own, so the
693693
* composer reserves the footer bar's band on its first frame. */
694694
hasConfiguredFooter?: boolean;
695+
/** The deployment's `interface.landingFooter`, so the welcome screen does
696+
* not paint a footer the config is about to remove. */
697+
landingFooter?: boolean;
695698
};
696699
}
697700
}

‎client/src/components/Chat/ChatView.tsx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
useFileMapContext,
2525
ComposerRestoreProvider,
2626
} from '~/Providers';
27+
import Footer, { useLandingFooter, useConfiguredFooter } from './Footer';
2728
import { QueuedTurnPortalProvider } from './Steering/QueuedTurnPortal';
2829
import { useChatSettings } from '~/Providers/ChatSettingsContext';
2930
import ApprovalProvider from './Messages/Content/ApprovalContext';
@@ -33,7 +34,6 @@ import ProjectBadge from '~/components/Projects/ProjectBadge';
3334
import { composerLiftFamily } from './Input/Composer/state';
3435
import { OwnerTextProvider } from './Messages/PrivateText';
3536
import { useGetMessagesByConvoId } from '~/data-provider';
36-
import Footer, { useConfiguredFooter } from './Footer';
3737
import { AskAnswerHostProvider } from './ask/state';
3838
import MessagesView from './Messages/MessagesView';
3939
import Presentation from './Presentation';
@@ -86,6 +86,7 @@ function ChatView({
8686
* composer's clearance has to account for the bar when it does — including
8787
* before the config answers, so a cold load does not jump. */
8888
const configuredFooter = useConfiguredFooter();
89+
const landingFooter = useLandingFooter();
8990

9091
/** Room an open composer popover needs below the composer; see the atom. */
9192
const composerLift = useAtomValue(composerLiftFamily(index));
@@ -149,11 +150,10 @@ function ChatView({
149150
(!messagesTree || messagesTree.length === 0) &&
150151
(conversationId === Constants.NEW_CONVO || !conversationId);
151152

152-
/** A footer bar renders beneath the composer on the welcome screen always, and
153-
* in a conversation when the deployment configured one. The shell already
154-
* carried that answer, so this is the same value before and after the config
155-
* resolves. */
156-
const footerBelow = isLandingPage || configuredFooter;
153+
/** The welcome screen carries the full footer unless the deployment turned it
154+
* off; otherwise a footer bar renders only for configured content. */
155+
const fullFooter = isLandingPage && landingFooter;
156+
const footerBelow = fullFooter || configuredFooter;
157157
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
158158
const isProjectLandingPage = isLandingPage && project != null;
159159

@@ -280,15 +280,15 @@ function ChatView({
280280
{/* The generic disclaimer and the policy links are the
281281
welcome screen's; a deployment's own footer stays
282282
with the conversation that always showed it. */}
283-
{!isLandingPage && configuredFooter && <Footer configuredOnly />}
283+
{!fullFooter && configuredFooter && <Footer configuredOnly />}
284284
</div>
285285
<Lia
286286
bandRef={composerBandRef}
287287
landing={isLandingPage}
288288
submission={rootSubmission}
289289
/>
290290
</div>
291-
{isLandingPage && <Footer />}
291+
{fullFooter && <Footer />}
292292
</>
293293
</TraceSurface>
294294
</Presentation>

‎client/src/components/Chat/Footer.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ export function useConfiguredFooter(): boolean {
5353
return isSuccess ? hasConfiguredFooter(config) : shellHasConfiguredFooter;
5454
}
5555

56+
/** The deployment's `interface.landingFooter`, as the server said when it served
57+
* this document. A shell that carries no answer reads as the default: on. */
58+
const shellLandingFooter =
59+
typeof window === 'undefined' || window.__LIBRECHAT_CONFIG__?.landingFooter !== false;
60+
61+
/** Whether the welcome screen carries the generic disclaimer and policy links.
62+
* Off, it keeps only a configured footer, the same as a conversation. Until
63+
* `/api/config` answers, the shell's answer holds, so a cold load lays the
64+
* welcome screen out once instead of dropping a footer it painted. */
65+
export function useLandingFooter(): boolean {
66+
const { data: config, isSuccess } = useGetStartupConfig();
67+
68+
return isSuccess ? config?.interface?.landingFooter !== false : shellLandingFooter;
69+
}
70+
5671
function Footer({ className, startupConfig, configuredOnly = false }: FooterProps) {
5772
const shouldFetchConfig = startupConfig === undefined;
5873
const { data: fetchedConfig } = useGetStartupConfig({ enabled: shouldFetchConfig });

‎client/src/components/Chat/Input/ChatForm.tsx‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import PromptsCommand from './PromptsCommand';
7777
import { submitFromComposer } from './submit';
7878
import SkillsCommand from './SkillsCommand';
7979
import AutoPlayAudio from './AutoPlayAudio';
80+
import composerClearance from './clearance';
8081
import Waveform from './Composer/Waveform';
8182
import { mainTextareaId } from '~/common';
8283
import CollapseChat from './CollapseChat';
@@ -712,22 +713,11 @@ const ChatForm = memo(function ChatForm({
712713
[isCollapsed],
713714
);
714715

715-
/* From `sm` up the band leaves room under itself for the disclaimer, which only
716-
the landing page carries — doubled while the centred landing composer floats,
717-
and dropped back the moment a submission starts the thread. A started
718-
conversation has nothing beneath it, so it keeps only enough to clear the
719-
surface's own shadow. Below `sm` the composer runs to the viewport floor in
720-
every state. */
721-
const landingClearance =
722-
centerFormOnLanding && !isSubmitting ? 'transition-all duration-200 sm:mb-28' : 'sm:mb-10';
723-
let bottomClearance = 'sm:mb-4';
724-
if (isLandingPage) {
725-
bottomClearance = landingClearance;
726-
} else if (footerBelow) {
727-
/* A conversation that carries a configured footer keeps the band that bar
728-
needs, exactly as the welcome screen does. */
729-
bottomClearance = 'sm:mb-10';
730-
}
716+
const bottomClearance = composerClearance({
717+
isLandingPage,
718+
footerBelow,
719+
floating: centerFormOnLanding && !isSubmitting,
720+
});
731721

732722
/** Answer mode, then during-run steering or queueing (a run in flight, or a
733723
* queued follow-up about to start), then an ordinary send: the same route
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import composerClearance from '../clearance';
2+
3+
describe('composerClearance', () => {
4+
test('reserves the footer band on the welcome screen, doubled while the composer floats', () => {
5+
expect(composerClearance({ isLandingPage: true, footerBelow: true, floating: true })).toBe(
6+
'transition-all duration-200 sm:mb-28',
7+
);
8+
expect(composerClearance({ isLandingPage: true, footerBelow: true, floating: false })).toBe(
9+
'sm:mb-10',
10+
);
11+
});
12+
13+
test('drops the footer band on a welcome screen with no footer beneath it', () => {
14+
expect(composerClearance({ isLandingPage: true, footerBelow: false, floating: true })).toBe(
15+
'sm:mb-4',
16+
);
17+
expect(composerClearance({ isLandingPage: true, footerBelow: false, floating: false })).toBe(
18+
'sm:mb-4',
19+
);
20+
});
21+
22+
test('reserves the band in a conversation only for a configured footer', () => {
23+
expect(composerClearance({ isLandingPage: false, footerBelow: true, floating: false })).toBe(
24+
'sm:mb-10',
25+
);
26+
expect(composerClearance({ isLandingPage: false, footerBelow: false, floating: false })).toBe(
27+
'sm:mb-4',
28+
);
29+
});
30+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Room the composer band leaves beneath itself from `sm` up. A footer bar sits
3+
* there whenever `footerBelow` holds, and the band reserves it: doubled while
4+
* the centred landing composer floats, dropped back the moment a submission
5+
* starts the thread. With no bar beneath it, the band keeps only enough to
6+
* clear the surface's own shadow, on the welcome screen as in a conversation.
7+
* Below `sm` the composer runs to the viewport floor in every state.
8+
*/
9+
export default function composerClearance({
10+
isLandingPage,
11+
footerBelow,
12+
floating,
13+
}: {
14+
isLandingPage: boolean;
15+
footerBelow: boolean;
16+
floating: boolean;
17+
}): string {
18+
if (!footerBelow) {
19+
return 'sm:mb-4';
20+
}
21+
if (isLandingPage && floating) {
22+
return 'transition-all duration-200 sm:mb-28';
23+
}
24+
return 'sm:mb-10';
25+
}

‎client/src/components/Chat/Landing.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
6464
});
6565
}, [conversation?.endpoint, conversation?.iconURL, endpointsConfig]);
6666

67-
const { entity, isAgent, isAssistant } = getEntity({
67+
const { entity, isAgent } = getEntity({
6868
endpoint: endpointType,
6969
agentsMap,
7070
assistantMap,
@@ -79,7 +79,10 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
7979

8080
const brandedSpecLabel = modelSpec?.showOnLanding ? modelSpec.label : '';
8181
const brandedSpecDescription = (modelSpec?.showOnLanding && modelSpec.description) || '';
82-
const name = isTemporary ? '' : (entity?.name ?? brandedSpecLabel);
82+
const name =
83+
isTemporary || startupConfig?.interface?.landingHeading === 'greeting'
84+
? ''
85+
: (entity?.name ?? brandedSpecLabel);
8386
const description = isTemporary
8487
? localize('com_ui_temporary_description')
8588
: ((entity?.description || brandedSpecDescription || conversation?.greeting) ?? '');
@@ -171,7 +174,7 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
171174
</TooltipAnchor>
172175
)}
173176
</div>
174-
{((isAgent || isAssistant) && name) || name ? (
177+
{name ? (
175178
<div className="flex flex-col items-center gap-0 p-2">
176179
<SplitText
177180
key={`split-text-${name}`}

‎client/src/components/Chat/__tests__/ChatView.spec.tsx‎

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@ jest.mock('../Presentation', () => ({
4646
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
4747
}));
4848
jest.mock('../Header', () => ({ __esModule: true, default: () => <div /> }));
49+
let mockConfiguredFooter = false;
50+
let mockLandingFooter = true;
4951
jest.mock('../Footer', () => ({
5052
__esModule: true,
51-
default: () => <div />,
52-
useConfiguredFooter: () => false,
53+
default: ({ configuredOnly }: { configuredOnly?: boolean }) => (
54+
<div data-testid={configuredOnly ? 'configured-footer' : 'landing-footer'} />
55+
),
56+
useConfiguredFooter: () => mockConfiguredFooter,
57+
useLandingFooter: () => mockLandingFooter,
5358
}));
5459
jest.mock('../Landing', () => ({ __esModule: true, default: () => <div /> }));
5560
jest.mock('~/components/Lia', () => ({ __esModule: true, default: () => null }));
@@ -213,3 +218,48 @@ describe('ChatView draft saving', () => {
213218
expect(mockAskHostSaveDrafts).toHaveBeenLastCalledWith(saveDrafts);
214219
});
215220
});
221+
222+
describe('ChatView landing footer', () => {
223+
beforeEach(() => {
224+
mockParams.mockReturnValue({});
225+
mockConversation.mockReturnValue(null);
226+
mockConfiguredFooter = false;
227+
mockLandingFooter = true;
228+
mockChatFormProps.mockClear();
229+
});
230+
231+
test('renders the full footer beneath the welcome screen by default', () => {
232+
render(<ChatView />);
233+
234+
expect(screen.getByTestId('landing-footer')).toBeInTheDocument();
235+
expect(screen.queryByTestId('configured-footer')).not.toBeInTheDocument();
236+
expect(mockChatFormProps).toHaveBeenLastCalledWith(
237+
expect.objectContaining({ footerBelow: true }),
238+
);
239+
});
240+
241+
test('drops the welcome screen footer when the deployment turns it off', () => {
242+
mockLandingFooter = false;
243+
244+
render(<ChatView />);
245+
246+
expect(screen.queryByTestId('landing-footer')).not.toBeInTheDocument();
247+
expect(screen.queryByTestId('configured-footer')).not.toBeInTheDocument();
248+
expect(mockChatFormProps).toHaveBeenLastCalledWith(
249+
expect.objectContaining({ footerBelow: false }),
250+
);
251+
});
252+
253+
test('keeps a configured footer on the welcome screen when the full footer is off', () => {
254+
mockLandingFooter = false;
255+
mockConfiguredFooter = true;
256+
257+
render(<ChatView />);
258+
259+
expect(screen.queryByTestId('landing-footer')).not.toBeInTheDocument();
260+
expect(screen.getByTestId('configured-footer')).toBeInTheDocument();
261+
expect(mockChatFormProps).toHaveBeenLastCalledWith(
262+
expect.objectContaining({ footerBelow: true }),
263+
);
264+
});
265+
});

0 commit comments

Comments
 (0)