Skip to content

Commit 1bea7e4

Browse files
authored
🧽 style: Composer, Welcome Screen and Context Panel Polish (#15838)
* 🕵️ style: Mark Temporary Chat with the Incognito Icon The dashed speech bubble (`MessageCircleDashed`) read as a generic chat glyph rather than a retention cue, and its dashes break up at 16px. Every temporary chat surface now uses lucide's `hat-glasses`, the hat-and-glasses incognito mark: the header toggle, the read-only indicator chip once a conversation has started, the overflow menu row, and the landing empty state. `hat-glasses` landed in lucide 0.528.0, so `lucide` and `lucide-react` move from `^0.525.0` to `^0.528.0` in `client` and `packages/client` (dependencies and peers). That is the smallest bump that carries the icon; lucide redraws existing icons between minors and the whole UI draws from it, so jumping to the current 0.577 would bring unrelated visual churn. `useChatBadges` imported the old icon without using it; the import is dropped. * 🧹 style: Keep the Model Disclaimer on the Landing Page Only The footer disclaimer rendered under the composer in every conversation, where it repeats itself on each turn and costs a line of the thread. It now renders only on the landing page, where it is first seen. `Footer` is untouched, as are its other hosts: the auth screens and the public shared-link view still render it. The component is a zero-height `relative` wrapper around an `absolute bottom-0` bar, so dropping it from the conversation branch shifts no layout. * 📱 fix: Run the Mobile Composer to the Screen Edges Below `sm` the composer is full-bleed — no horizontal padding, squared off at the bottom, flush with the viewport floor — but it was inset on two sides there: - `.scrollbar-gutter-spacer` reserved the message column's scrollbar band (8px) on the composer's trailing edge. That lines the composer up with the thread on desktop, but on mobile it only cut a dead strip off its right side, so the padding now applies from `sm` up. Its two users, the composer band and the scroll-to-bottom control, move together, so the control still stacks over the send button. - The composer surface carried `pb-4` under its action row below `sm` (`sm:pb-0` above), leaving 16px of empty surface between the buttons and the bottom of the screen. Desktop metrics are unchanged: the band still reserves the gutter, the composer still clears the floor by `sm:mb-10`, and the surface still resolves to `pb-0`. * 🔅 style: Mute the Landing Page Disclaimer The footer sat in `text-primary`, the same weight as the greeting above it, so the least important line on the landing page read as one of the most prominent — hardest to ignore in the light theme, where it lands at 16.1:1 on white. It now takes `text-muted`, the quietest text token that still clears AA for 12px copy on `bg-presentation`: 5.11:1 on white and 7.93:1 on the dark canvas. The contrast modes collapse every text token to pure black or white, so `high-contrast-light` and `high-contrast-dark` keep the disclaimer at 21:1. The links move with it instead of keeping the brighter `text-secondary`: the underline carries the affordance, and a link that outshines its own sentence puts the emphasis back where this change takes it from. The text stays at the bottom of the page. The landing hierarchy is the composer and its title first, the sidebar second, and moving the disclaimer under the text field would place it above both. * 💠 style: Draw the Empty Prompts Panel Like Its Neighbours The prompts side panel hand-rolled its own empty card — the same circular icon, title and caption as the bookmarks, memories and schedules panels, but written out again and a shade off: `border-border-medium` where the shared card uses `border-border-light`. It now renders the `EmptyState` primitive the other panels use, which is where that appearance is owned. The strings, the icon and the list's own `my-2` are unchanged, so the panel reads the same apart from the border it was never meant to differ on. * 📐 fix: Close the Gap Under the Composer in a Conversation From `sm` up the composer band left 40px under itself, the height of the disclaimer that used to sit there. A started conversation no longer carries one, so the composer floated a line above the viewport floor with nothing in the gap. The clearance now follows what is underneath. `ChatView` owns that fact — it is the same `isLandingPage` it gates the footer on — and passes it to `ChatForm`: - landing, centred composer: `sm:mb-28`, unchanged - landing, composer at the bottom: `sm:mb-10`, the clearance the disclaimer needs, unchanged - started conversation: `sm:mb-4`, enough to show the surface's own shadow Below `sm` the composer still runs to the viewport floor in every state. `ChatForm` cannot answer this for itself: deriving it from `conversation?.messages?.length === 0` reads false while `messages` is undefined, which silently drops the un-centred landing page to the conversation clearance and overlaps the disclaimer. `SubagentThreadPanel` states that its own bottom padding matches the main composer's so the two surfaces end on one line when the panel is open beside a thread, so it moves with it. * 🎞️ fix: Slide the Composer Between Welcome Screen and Conversation With "Center Chat Input on Welcome Screen" off, the welcome composer sits at the bottom with the disclaimer's clearance under it and a conversation's sits 24px lower. React keeps the same form node across that navigation, so the band had everything it needed to travel and jumped instead: `margin-bottom`, the property that carries it, was not in the transition list — only `max-width` was, from the chat-width preference. `margin-bottom` now transitions with it, 300ms on Tailwind's default easing, in both directions and from the centred welcome composer as well. Reduced motion gets the new position outright (`motion-reduce:transition-none`): this is a slide across the page, not decoration. * ↔️ fix: Hold the Composer Still Across the Welcome Screen Opening a conversation from the welcome screen slid the composer 4px to the left. A conversation's composer band reserves the band the message column holds back for its scrollbar (`.scrollbar-gutter-spacer`, 8px here), which is what lines the composer up with the messages; the welcome screen reserved nothing, so its composer was centred on the full region and half the gutter off from where it was about to land. Now that `margin-bottom` animates, the sideways step ran alongside the downward one. The gutter is reserved once per state, wherever the centring happens: the conversation keeps it on the band around the composer, and the welcome screen takes it on the column that centres greeting and composer together, so both move as one and the composer lands where the message column will put it. Measured across the navigation — 1280 and 1024 wide, centred and bottom composer, sidebar open and collapsed: the surface keeps its exact left edge and width. Below `sm` the reservation does not apply and the composer stays full-bleed on both sides of the navigation. * 📏 fix: Measure the Scrollbar Gutter Before the Welcome Screen Reserves It The welcome screen holds back the band the message column reserves for its scrollbar, so the composer lands where the message column will put it. On a fresh load no message column has mounted, so `useScrollbarGutter` has published nothing and the spacer fell back to `--scrollbar-size`: a fixed 8px, correct only where the app's own `::-webkit-scrollbar` width applies. On an overlay-scrollbar platform the real band is zero, so the welcome screen started 4px off-centre and the composer stepped sideways as soon as the first conversation measured the band for real — the jump this change set out to remove, inverted. The gutter is now seeded from a detached probe built like the column itself (`overflow-y: auto` with `scrollbar-gutter: stable`), which answers the same question for the platform in front of the user: the scrollbar width, and zero where scrollbars overlay. A mounted column measures itself and is authoritative, so the seed only fills the gap before the first one exists and never overwrites a published measurement. Reported by Codex on PR #15838: #15838 (comment) * 🧪 test: Cover the Composer and Welcome Screen Behaviours Nine browser scenarios for what this change promises, in the mock harness against the in-process fake model, tagged so each one is addressable: - `welcome-screen-disclaimer-only`, `welcome-disclaimer-readable-while-muted`: the disclaimer stays on the welcome screen, leaves the conversation, and clears AA contrast against the canvas it is painted on — the dark project measures the dark palette, the light one the light palette. - `composer-holds-position-into-conversation`, `composer-holds-position-with-overlay-scrollbars`: the composer keeps its left edge and width across the navigation and lines up with the message column, both with the app's own scrollbars and on a platform whose scrollbars reserve nothing. - `composer-slides-down-into-conversation`, `composer-settles-instantly-under-reduced-motion`: the clearance change runs as a `margin-bottom` transition on the composer's own form, and runs no transition at all under `prefers-reduced-motion`. - `mobile-composer-reaches-screen-edges`: below `sm` the surface spans the viewport and its action row is the last thing in it. - `temporary-chat-marked-with-incognito-icon`: turning the mode on marks the toggle and the welcome screen with the outline incognito mark. - `empty-prompts-panel-matches-other-panels`: the empty prompts card and the empty bookmarks card are drawn identically. Each scenario skips the projects whose viewport it does not describe, rather than asserting a desktop layout under mobile emulation. * 🩹 fix: Seed the Gutter Before Paint and Keep Configured Footers Three findings from the review round on eec4b37, one root cause each. The gutter seed measured in a passive effect, so the welcome screen's first frame still reserved the `--scrollbar-size` token and recentred by 4px once the real band was known — the load-time half of the shift this reservation removes. It measures in a layout effect now, before the browser paints. Scoping the disclaimer to the welcome screen also unmounted the only authenticated-chat renderer of `customFooter`, and of a deployment's privacy policy and terms links. `Footer` takes `configuredOnly`: the generic disclaimer is dropped, operator content is kept, and with nothing configured it renders nothing rather than an empty bar. The conversation renders it again under that scope. The mobile composer spec gated itself on viewport width, and the repository's mock config has one Desktop Chrome project, so CI skipped it and reported nothing. It declares the viewport it describes with `test.use`. Two scenarios cover the first two: - `welcome-composer-paints-in-its-final-position`: samples the composer's x from the document's first frame on a platform whose scrollbars reserve nothing, and fails on any later correction. - `configured-footer-stays-in-a-conversation`: serves a configured `customFooter` through the startup-config route and finds it on both the welcome screen and inside a conversation. Findings: #15838 (comment) #15838 (comment) #15838 (comment) * 🧹 test: Drop the First-Paint Sampler, Keep the Layout Effect The sampler recorded the composer's x from the document's first frame to catch a late recentring on a platform whose scrollbars reserve nothing. It cannot fail: with the seed measured in a passive effect it still recorded a single position in all three projects, because React flushes that effect before the browser paints the commit it belongs to. A test that passes with and without the behaviour it describes pins nothing. The measurement stays in a layout effect: a value the layout reads belongs before paint by construction, whatever a given runtime's flush order happens to be. The reservation itself is covered by `composer-holds-position-with-overlay-scrollbars`, which does fail without it — 4px, the half-band Codex reported. * 🧷 fix: Clear the Configured Footer and Remeasure the Band on Contrast Two findings from the round on d942b7c, one invariant each. Keeping a configured footer in conversations left the composer reserving 16px above an absolutely positioned bar about 32px tall, so the bar painted over the composer's action row and would have taken its clicks. The clearance follows what actually renders beneath the composer now, not which page it is: `ChatView` reads `useConfiguredFooter` once and both decisions — whether to render the footer, and how much room to leave for it — come from that same answer. `ChatForm` takes `footerBelow` and keeps the welcome screen's rhythm for the welcome screen. The band is not a constant of the platform either: the contrast modes widen the app's own scrollbar to 0.75rem, and a reader can turn contrast on — or have the OS turn it on — while the welcome screen is the only thing mounted, with no message column to republish. The seed remeasures when `ThemeProvider` flips a class on the document element, which is the thing that changed the band. Two scenarios, both failing before the fixes: - `configured-footer-clears-the-composer`: with `customFooter` configured, the bar sits at or below the composer's bottom edge and the composer still takes a click at its own bottom row. - `composer-holds-position-across-a-contrast-switch`: contrast turned on while the welcome screen is up, then a conversation opened; the composer keeps its left edge and width. Findings: #15838 (comment) #15838 (comment) * 🪡 fix: Align the Subagent Panel With the Conversation's Footer The panel's bottom band states that it matches the main composer's clearance so the two surfaces end on one line beside each other. That clearance became footer-dependent in the previous commit, and the panel kept a fixed `pb-4`, so a deployment with a configured footer ended them 24px apart. The panel reads the same answer the composer reads — `useConfiguredFooter` — and clears the bar when there is one. Three consumers now share one source for the question "does a footer render beneath this surface": `ChatView`, `ChatForm` through `footerBelow`, and this panel. The panel's own alignment has no browser scenario: reaching this surface needs an agent with subagents, a dispatched task and an open control footer, which is a fixture of its own. The shared cause is covered by `configured-footer-clears-the-composer` on the main composer. Reported by Codex on PR #15838: #15838 (comment) * 🧵 fix: Hold the Footer Clearance Through a Cold Load and a Stale Band Three findings from the round on 55106cd. Whether a conversation carries a footer is an answer from the startup config, and on a cold `/c/<id>` load it arrives after the composer is painted. `useConfiguredFooter` now reports `{ present, resolved }`, and the two readers use them differently on purpose: nothing renders until the answer arrives, because there is nothing to render, but the clearance is held from the first frame, because guessing "no footer" moves the composer twice. The measured scrollbar band outlives the chat screen on purpose, and the screens it outlives can change it — the auth layout carries a theme selector, and a contrast switch there widens the app's own scrollbar while no chat column is watching. The seed measures on every mount now instead of trusting a value it did not publish. `@librechat/client`'s peer range kept its previous line as well as the new one (`^0.525.0 || ^0.528.0`): only `/client` consumes `HatGlasses`, and the shared package still builds against either, so raising the published minimum would have rejected downstream installs for an icon it does not use. Its devDependency stays on 0.528, which is what it is tested against. Two scenarios, both failing before the fixes: - `configured-footer-clearance-survives-a-cold-load`: the startup config is served 700ms late with a custom footer, and the composer's bottom is sampled from the document's first frame; any correction fails it. - `welcome-screen-remeasures-a-stale-gutter`: a 40px reservation is left behind before the app boots, as another screen would, and the welcome screen has to replace it rather than inherit it. Findings: #15838 (comment) #15838 (comment) #15838 (comment) * 🧠 fix: Remember the Footer Answer Instead of Guessing It Reserving the footer's band while the startup config was in flight fixed the cold load of a deployment that configures a footer and broke the cold load of one that does not: the composer took `sm:mb-10`, then dropped 24px when the answer said there was nothing to clear. Both defaults are wrong for one of the two deployments, because the layout was being derived from an answer that does not exist yet. `useConfiguredFooter` remembers the answer instead. A deployment's footer configuration is a deployment-lifetime fact, so the last answer it gave is the right thing to lay out against while `/api/config` is in flight; it is recorded once the query resolves. Every load after the first is exact in either kind of deployment, and a first-ever visit falls back to LibreChat's default — no configured footer — and settles once if the deployment disagrees. `ChatView` and the subagent panel drop the `!resolved` term: `present` already carries the remembered answer, so the value is the same before and after the config answers. Scenarios, both sampling the composer's bottom from the document's first frame with the config served 700ms late: - `configured-footer-clearance-survives-a-cold-load` now primes the answer with a first visit and asserts the load after it, which is the load a returning user gets. - `default-clearance-survives-a-cold-load` covers the other branch: a deployment that configures nothing must not move either. Reported by Codex on PR #15838: #15838 (comment) * 🛟 fix: Treat a Failed Startup Config as No Answer React Query reports a request that exhausted its retries as fetched, with no data, so reading `isFetched` turned a failed `/api/config` into a confirmed "nothing configured": a deployment remembered as having a footer lost the clearance that footer needs, and the memory was overwritten with the guess, so the composer moved again on the next load that succeeded. `useConfiguredFooter` reads `isSuccess`. A failure now answers nothing: the remembered deployment state stands, and nothing is recorded until a response actually arrives. `config-failure-keeps-the-remembered-footer` covers it: a first visit records a configured footer, a second visit is served 500s until the retries run out, and the composer's clearance has to be the one it had. Reported by Codex on PR #15838: #15838 (comment) * 🧽 test: Drop the Config-Failure Scenario, Keep the Success Check The scenario recorded a configured footer, served `/api/config` 500s until the retries ran out, and then asserted the next successful load lays out once. It cannot fail: without a startup config the app renders no chat at all, so `useConfiguredFooter` is never mounted on the failing visit and nothing is recorded either way. The negative control — reading `isFetched` again — passes it in all three projects. The reported corruption therefore has no reachable path: the hook only observes a failed query while the chat is mounted, which requires a config that answered once, and React Query keeps that answer as cached data through a later failure. `isSuccess` stays. "A failure answers nothing" is what the code should say, and saying it costs nothing; the test that pretended to prove it does not stay. Reported by Codex on PR #15838: #15838 (comment) * 🧱 refactor: Own the Footer Memory in Jotai and Take the Landing Preference In Two repository-policy findings from the round on 5d535a6. The remembered footer answer was a raw `localStorage` read and write, which is the persistence path the client state rules replaced: it is feature-owned state that the chat surface both writes and reads, so it is a Jotai storage atom now (`configuredFooterAtom`, beside the feature that owns it). `getOnInit` is what makes it usable — the value has to be there on the first render, or it is the guess again — and only a successful config response writes to it. `ChatForm` also still subscribed to `centerFormOnLanding` directly, an app-level persisted preference the chat feature only consumes. `ChatView` already reads it for `Landing`, and now passes it in beside `isLandingPage` and `footerBelow`, so the composer's clearance is computed entirely from what the host handed it. Findings: #15838 (comment) #15838 (comment) * 🔍 fix: Give the Context Popover Its Own Foreground The popover is portaled to the body with a surface colour and no text colour, so anything inheriting \ there took the user-agent default black: the insights toggle rendered at 1.3:1 on the dark canvas and was invisible until hovered. The panel now names \ beside its surface, as the shared hover-card surface already does. * 🧮 fix: Show the Cached Share Beside Its Tokens The cached and cache-write rows were built without a max, and the row renders its share only when one is given, so both printed a bare token count while every row around them carried a percentage. Both now key the window, and the cached test pins the share. The insights toggle also names its resting colour here: the ghost variant defines only a hover colour, and every other glyph in the panel names a role.
1 parent 8f13845 commit 1bea7e4

34 files changed

Lines changed: 1342 additions & 79 deletions

client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@
8080
"js-cookie": "^3.0.5",
8181
"librechat-data-provider": "*",
8282
"lodash": "^4.17.23",
83-
"lucide": "^0.525.0",
84-
"lucide-react": "^0.525.0",
83+
"lucide": "^0.528.0",
84+
"lucide-react": "^0.528.0",
8585
"match-sorter": "^8.1.0",
8686
"mdast-util-directive": "^3.0.0",
8787
"mdast-util-from-markdown": "^2.0.1",

client/src/components/Chat/ChatView.tsx

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Constants, buildTree } from 'librechat-data-provider';
88
import type { TChatProject } from 'librechat-data-provider';
99
import type { ChatFormValues } from '~/common';
1010
import {
11+
useScrollbarGutterSeed,
1112
useAddedResponse,
1213
useResumeOnLoad,
1314
useAdaptiveSSE,
@@ -20,13 +21,13 @@ import ApprovalProvider from './Messages/Content/ApprovalContext';
2021
import ConversationStarters from './Input/ConversationStarters';
2122
import { pendingApprovalActionFamily } from './approval/state';
2223
import { useGetMessagesByConvoId } from '~/data-provider';
24+
import Footer, { useConfiguredFooter } from './Footer';
2325
import { AskAnswerHostProvider } from './ask/state';
2426
import MessagesView from './Messages/MessagesView';
2527
import Presentation from './Presentation';
2628
import ChatForm from './Input/ChatForm';
2729
import Landing from './Landing';
2830
import Header from './Header';
29-
import Footer from './Footer';
3031
import { cn } from '~/utils';
3132
import store from '~/store';
3233

@@ -51,6 +52,15 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
5152
pendingApprovalActionFamily(conversationId ?? Constants.NEW_CONVO),
5253
);
5354

55+
/** The welcome screen reserves the message column's scrollbar band before any
56+
* column exists to measure it (see the column's class list below). */
57+
useScrollbarGutterSeed();
58+
59+
/** A conversation carries a footer only for configured content, and the
60+
* composer's clearance has to account for the bar when it does — including
61+
* while the config is still in flight, so a cold load does not jump. */
62+
const configuredFooter = useConfiguredFooter();
63+
5464
const methods = useForm<ChatFormValues>({
5565
defaultValues: { text: '' },
5666
});
@@ -101,6 +111,12 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
101111
const isLandingPage =
102112
(!messagesTree || messagesTree.length === 0) &&
103113
(conversationId === Constants.NEW_CONVO || !conversationId);
114+
115+
/** A footer bar renders beneath the composer on the welcome screen always, and
116+
* in a conversation when the deployment configured one. `present` already
117+
* carries the remembered answer while the config is in flight, so this is the
118+
* same value before and after it resolves. */
119+
const footerBelow = isLandingPage || configuredFooter.present;
104120
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
105121
const isProjectLandingPage = isLandingPage && project != null;
106122

@@ -151,7 +167,15 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
151167
className={cn(
152168
'flex flex-col',
153169
isLandingPage
154-
? 'flex-1 items-center justify-end sm:justify-center'
170+
? /* The gutter is reserved once per state, wherever the
171+
centring happens. A conversation centres the composer
172+
inside the band below, against a message column that
173+
holds the scrollbar band back; the landing page centres
174+
this whole column instead, greeting and composer
175+
together, so it holds the same band back here. Without
176+
it the composer lands 4px right of where a conversation
177+
puts it and slides sideways on the way in. */
178+
'scrollbar-gutter-spacer flex-1 items-center justify-end sm:justify-center'
155179
: 'h-full overflow-y-auto',
156180
)}
157181
>
@@ -181,9 +205,15 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
181205
index={index}
182206
placeholder={chatFormPlaceholder}
183207
project={isProjectLandingPage ? project : undefined}
208+
isLandingPage={isLandingPage}
209+
footerBelow={footerBelow}
210+
centerFormOnLanding={centerFormOnLanding}
184211
/>
185212
)}
186-
{!isLandingPage && <Footer />}
213+
{/* The generic disclaimer is the welcome screen's; a
214+
deployment's own footer, privacy policy and terms
215+
stay with the conversation that always showed them. */}
216+
{!isLandingPage && configuredFooter.present && <Footer configuredOnly />}
187217
</div>
188218
</div>
189219
{isLandingPage && <Footer />}

client/src/components/Chat/Footer.tsx

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,69 @@
11
import React, { useEffect, memo } from 'react';
2+
import { useAtom } from 'jotai';
23
import TagManager from 'react-gtm-module';
34
import ReactMarkdown from 'react-markdown';
45
import { Constants } from 'librechat-data-provider';
56
import type { TStartupConfig } from 'librechat-data-provider';
67
import { useGetStartupConfig } from '~/data-provider';
8+
import { configuredFooterAtom } from './footerMemory';
79
import { useLocalize } from '~/hooks';
810

911
type FooterProps = {
1012
className?: string;
1113
startupConfig?: FooterStartupConfig | null;
14+
/** A started conversation keeps only what the deployment configured. The
15+
* generic model disclaimer belongs to the welcome screen, where it is first
16+
* read, but a custom footer, a privacy policy and terms of service are the
17+
* operator's own content: scoping the disclaimer out must not take their
18+
* configuration off the screen that used to carry it. With nothing
19+
* configured, this renders nothing at all. */
20+
configuredOnly?: boolean;
1221
};
1322

1423
type FooterStartupConfig = Pick<Partial<TStartupConfig>, 'analyticsGtmId' | 'customFooter'> & {
1524
interface?: Pick<NonNullable<TStartupConfig['interface']>, 'privacyPolicy' | 'termsOfService'>;
1625
};
1726

18-
function Footer({ className, startupConfig }: FooterProps) {
27+
export type ConfiguredFooter = {
28+
/** Whether a footer bar belongs under the composer in a conversation: the
29+
* deployment configured a custom footer, a privacy policy or terms of
30+
* service. Before the startup config answers this is the answer the last
31+
* load recorded, which is what keeps a cold load from laying out twice. */
32+
present: boolean;
33+
/** The startup config has answered on this load. */
34+
resolved: boolean;
35+
};
36+
37+
/**
38+
* What a conversation has to render beneath its composer. The conversation
39+
* renders the footer only for configured content, and the composer above it
40+
* reserves the band that bar needs — the bar is absolutely positioned in a
41+
* zero-height wrapper, so a composer that did not reserve it would be painted
42+
* over. Both decisions read this one answer so they cannot disagree.
43+
*
44+
* While `/api/config` is in flight the answer is the one this deployment gave
45+
* last (`configuredFooterAtom`), so a cold load lays out once instead of
46+
* guessing and correcting. Only a successful response replaces it: a request
47+
* that exhausted its retries has answered nothing.
48+
*/
49+
export function useConfiguredFooter(): ConfiguredFooter {
50+
const { data: config, isSuccess } = useGetStartupConfig();
51+
const [remembered, remember] = useAtom(configuredFooterAtom);
52+
const configured =
53+
typeof config?.customFooter === 'string' ||
54+
config?.interface?.privacyPolicy?.externalUrl != null ||
55+
config?.interface?.termsOfService?.externalUrl != null;
56+
57+
useEffect(() => {
58+
if (isSuccess && configured !== remembered) {
59+
remember(configured);
60+
}
61+
}, [configured, isSuccess, remembered, remember]);
62+
63+
return { present: isSuccess ? configured : remembered, resolved: isSuccess };
64+
}
65+
66+
function Footer({ className, startupConfig, configuredOnly = false }: FooterProps) {
1967
const shouldFetchConfig = startupConfig === undefined;
2068
const { data: fetchedConfig } = useGetStartupConfig({ enabled: shouldFetchConfig });
2169
const config = shouldFetchConfig ? fetchedConfig : startupConfig;
@@ -25,25 +73,27 @@ function Footer({ className, startupConfig }: FooterProps) {
2573
const termsOfService = config?.interface?.termsOfService;
2674

2775
const privacyPolicyRender = privacyPolicy?.externalUrl != null && (
28-
<a className="text-text-secondary underline" href={privacyPolicy.externalUrl} rel="noreferrer">
76+
<a className="text-text-muted underline" href={privacyPolicy.externalUrl} rel="noreferrer">
2977
{localize('com_ui_privacy_policy')}
3078
</a>
3179
);
3280

3381
const termsOfServiceRender = termsOfService?.externalUrl != null && (
34-
<a className="text-text-secondary underline" href={termsOfService.externalUrl} rel="noreferrer">
82+
<a className="text-text-muted underline" href={termsOfService.externalUrl} rel="noreferrer">
3583
{localize('com_ui_terms_of_service')}
3684
</a>
3785
);
3886

39-
const mainContentParts = (
40-
typeof config?.customFooter === 'string'
41-
? config.customFooter
42-
: '[LibreChat ' +
43-
Constants.VERSION +
44-
'](https://librechat.ai) - ' +
45-
localize('com_ui_latest_footer')
46-
).split('|');
87+
const configuredFooter = typeof config?.customFooter === 'string' ? config.customFooter : null;
88+
/** The generic disclaimer is the part a conversation drops; operator content is not. */
89+
const genericFooter = configuredOnly
90+
? ''
91+
: '[LibreChat ' +
92+
Constants.VERSION +
93+
'](https://librechat.ai) - ' +
94+
localize('com_ui_latest_footer');
95+
const mainContent = configuredFooter ?? genericFooter;
96+
const mainContentParts = mainContent === '' ? [] : mainContent.split('|');
4797

4898
useEffect(() => {
4999
if (config?.analyticsGtmId != null && typeof window.google_tag_manager === 'undefined') {
@@ -61,7 +111,7 @@ function Footer({ className, startupConfig }: FooterProps) {
61111
a: ({ node: _n, href, children, ...otherProps }) => {
62112
return (
63113
<a
64-
className="text-text-secondary underline"
114+
className="text-text-muted underline"
65115
href={href}
66116
target="_blank"
67117
rel="noopener noreferrer"
@@ -84,12 +134,27 @@ function Footer({ className, startupConfig }: FooterProps) {
84134
Boolean,
85135
);
86136

137+
/** A conversation with no configured footer has nothing to place, so it does
138+
* not place an empty bar over the bottom of the thread. */
139+
if (footerElements.length === 0) {
140+
return null;
141+
}
142+
87143
return (
88144
<div className="relative w-full">
89145
<div
90146
className={
91147
className ??
92-
'absolute bottom-0 left-0 right-0 hidden items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-primary sm:flex md:px-[60px]'
148+
/* The disclaimer is the least important text on the landing page and
149+
sat in `text-primary`, the same weight as the greeting above it.
150+
`text-muted` is the quietest text token that still clears AA for
151+
12px copy on `bg-presentation` — 5.11:1 on white, 7.93:1 on the dark
152+
canvas — and the contrast modes collapse every text token to pure
153+
black or white, so they stay at 21:1. The links keep the same colour
154+
rather than the brighter `text-secondary`: the underline carries the
155+
affordance, and a link that outshines its own sentence puts the
156+
emphasis back where this change takes it from. */
157+
'absolute bottom-0 left-0 right-0 hidden items-center justify-center gap-2 px-2 py-2 text-center text-xs text-text-muted sm:flex md:px-[60px]'
93158
}
94159
>
95160
{footerElements.map((contentRender, index) => {

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

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ interface ChatFormProps {
7474
index: number;
7575
placeholder?: string;
7676
project?: TChatProject;
77+
/** Owned by ChatView: which layout the composer sits in — the welcome screen
78+
* floats or bottoms it out, a conversation ends the page with it. */
79+
isLandingPage: boolean;
80+
/** Owned by the host: the app-level preference for where the welcome-screen
81+
* composer sits. The chat feature only consumes it. */
82+
centerFormOnLanding: boolean;
83+
/** Owned by ChatView: whether a footer bar renders under this band. It is an
84+
* absolutely positioned bar in a zero-height wrapper, so the clearance here
85+
* is the only thing keeping it off the composer. True on the welcome screen,
86+
* which always carries one, and in a conversation whose deployment
87+
* configured footer content of its own. */
88+
footerBelow: boolean;
7789
/** From ChatContext: individual values so memo can compare them */
7890
files: Map<string, ExtendedFile>;
7991
setFiles: FileSetter;
@@ -107,6 +119,9 @@ const ChatForm = memo(function ChatForm({
107119
index,
108120
placeholder,
109121
project,
122+
isLandingPage,
123+
footerBelow,
124+
centerFormOnLanding,
110125
files,
111126
setFiles,
112127
conversation,
@@ -133,7 +148,6 @@ const ChatForm = memo(function ChatForm({
133148
const chatDirection = useRecoilValue(store.chatDirection);
134149
const automaticPlayback = useRecoilValue(store.automaticPlayback);
135150
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
136-
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
137151
const isTemporary = useRecoilValue(store.isTemporary);
138152

139153
const [badges, setBadges] = useRecoilState(store.chatBadges);
@@ -571,6 +585,23 @@ const ChatForm = memo(function ChatForm({
571585
[isCollapsed, isMoreThanThreeRows],
572586
);
573587

588+
/* From `sm` up the band leaves room under itself for the disclaimer, which only
589+
the landing page carries — doubled while the centred landing composer floats,
590+
and dropped back the moment a submission starts the thread. A started
591+
conversation has nothing beneath it, so it keeps only enough to clear the
592+
surface's own shadow. Below `sm` the composer runs to the viewport floor in
593+
every state. */
594+
const landingClearance =
595+
centerFormOnLanding && !isSubmitting ? 'transition-all duration-200 sm:mb-28' : 'sm:mb-10';
596+
let bottomClearance = 'sm:mb-4';
597+
if (isLandingPage) {
598+
bottomClearance = landingClearance;
599+
} else if (footerBelow) {
600+
/* A conversation that carries a configured footer keeps the band that bar
601+
needs, exactly as the welcome screen does. */
602+
bottomClearance = 'sm:mb-10';
603+
}
604+
574605
return (
575606
<form
576607
onSubmit={methods.handleSubmit((data) => {
@@ -592,14 +623,16 @@ const ChatForm = memo(function ChatForm({
592623
return submitMessage(data);
593624
})}
594625
className={cn(
595-
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
626+
/* `margin-bottom` is animated as well as `max-width`: it is what carries
627+
the composer between the landing clearance and the conversation one,
628+
and the landing page keeps the same form node when a conversation
629+
opens, so the band travels instead of jumping. The centred landing
630+
composer overrides both with its own `transition-all`, and a reader who
631+
asked for less motion gets the new position outright — this one is a
632+
slide across the page rather than decoration. */
633+
'mx-auto flex w-full flex-row gap-3 transition-[max-width,margin-bottom] duration-300 motion-reduce:transition-none sm:px-2',
596634
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
597-
centerFormOnLanding &&
598-
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
599-
!isSubmitting &&
600-
conversation?.messages?.length === 0
601-
? 'transition-all duration-200 sm:mb-28'
602-
: 'sm:mb-10',
635+
bottomClearance,
603636
)}
604637
>
605638
<div className="relative flex h-full min-w-0 flex-1 items-stretch md:flex-col">
@@ -650,7 +683,11 @@ const ChatForm = memo(function ChatForm({
650683
data-testid="composer-surface"
651684
onClick={handleContainerClick}
652685
className={cn(
653-
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl pb-4 sm:rounded-3xl sm:pb-0',
686+
/* The surface runs to the viewport floor below `sm`, where it is
687+
squared off at the bottom (`rounded-t-3xl`) and no disclaimer
688+
follows it — so the action row is the last thing in it, with no
689+
band of padding under the buttons. */
690+
'relative flex w-full flex-grow flex-col overflow-hidden rounded-t-3xl sm:rounded-3xl',
654691
composerSurfaceClasses(),
655692
isTextAreaFocused ? composerSurfaceShadow.focused : composerSurfaceShadow.blurred,
656693
/* Temporary-chat accent is a ChatForm-only override, not part of
@@ -885,10 +922,16 @@ function ChatFormWrapper({
885922
index = 0,
886923
placeholder,
887924
project,
925+
isLandingPage,
926+
footerBelow,
927+
centerFormOnLanding,
888928
}: {
889929
index?: number;
890930
placeholder?: string;
891931
project?: TChatProject;
932+
isLandingPage: boolean;
933+
footerBelow: boolean;
934+
centerFormOnLanding: boolean;
892935
}) {
893936
const {
894937
files,
@@ -953,6 +996,9 @@ function ChatFormWrapper({
953996
index={index}
954997
placeholder={placeholder}
955998
project={project}
999+
isLandingPage={isLandingPage}
1000+
footerBelow={footerBelow}
1001+
centerFormOnLanding={centerFormOnLanding}
9561002
files={files}
9571003
setFiles={setFiles}
9581004
conversation={stableConversation}

client/src/components/Chat/Input/TokenUsage/Breakdown.spec.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,10 @@ describe('TokenUsage Breakdown', () => {
423423
* the meter. */
424424
expect(cachedRow.parentElement?.className).toContain('pl-6');
425425
expect(cachedRow.textContent).toContain('30');
426+
/** The share column is keyed to the window, like every row above: without
427+
* it the cached row reads as a bare number beside rows that all carry a
428+
* percentage, with the cell left blank. */
429+
expect(cachedRow.textContent).toContain('(2%)');
426430
const peers = Array.from(breakdown.children).filter(
427431
(child) => !child.className.includes('pl-6'),
428432
);

0 commit comments

Comments
 (0)