diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 1ab9b3159e1f9..4d6b45d5524e9 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -22,6 +22,9 @@ const clientSettings: ClientSettings = {
notificationMode: "notifications-and-sound",
inAppNotificationsEnabled: true,
appearanceContrast: 100,
+ timelineBackgroundImage: "",
+ timelineBackgroundOpacity: 20,
+ timelineBackgroundBlur: 0,
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
browserDefaultZoomFactor: 1.25,
browserDefaultAppearance: "dark",
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index f4561188df0cc..a3814219db942 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1,4 +1,5 @@
import { ChildThreadApprovals } from "./chat/ChildThreadApprovals";
+import { ChatTimelineBackground } from "./chat/ChatTimelineBackground";
import { workspaceAreaProps } from "../lib/workspaceFocus";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits";
@@ -3371,8 +3372,9 @@ export default function ChatView(props: ChatViewProps) {
/>
+
settings.timelineBackgroundImage);
+ const opacity = useClientSettings((settings) => settings.timelineBackgroundOpacity);
+ const blur = useClientSettings((settings) => settings.timelineBackgroundBlur);
+ if (!image || opacity === 0) return null;
+ return (
+
+

{
+ event.currentTarget.style.visibility = "hidden";
+ }}
+ className="absolute inset-0 size-full object-cover"
+ style={{ opacity: opacity / 100, filter: blur > 0 ? `blur(${blur}px)` : undefined }}
+ />
+
+ );
+}
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 02c5f153961b2..d4d65caf5b429 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -1263,7 +1263,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
return (
<>
{activityAnnouncer}
-
+
>
);
}
diff --git a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx
index 00783b085d609..797524c4465ea 100644
--- a/apps/web/src/components/settings/AppearanceSettingsPanel.tsx
+++ b/apps/web/src/components/settings/AppearanceSettingsPanel.tsx
@@ -1,3 +1,4 @@
+import { TimelineBackgroundSettings } from "./TimelineBackgroundSettings";
import type { CSSProperties } from "react";
import { useState } from "react";
import {
@@ -162,6 +163,8 @@ export function AppearanceSettingsPanel() {
+
+
void) {
...(settings.appearanceContrast !== DEFAULT_UNIFIED_SETTINGS.appearanceContrast
? ["Contrast"]
: []),
+ ...(settings.timelineBackgroundImage !== DEFAULT_UNIFIED_SETTINGS.timelineBackgroundImage ||
+ settings.timelineBackgroundOpacity !== DEFAULT_UNIFIED_SETTINGS.timelineBackgroundOpacity ||
+ settings.timelineBackgroundBlur !== DEFAULT_UNIFIED_SETTINGS.timelineBackgroundBlur
+ ? ["Wallpaper"]
+ : []),
...(settings.glassOpacity !== DEFAULT_UNIFIED_SETTINGS.glassOpacity ? ["Glass opacity"] : []),
...(settings.diffColorScheme !== DEFAULT_UNIFIED_SETTINGS.diffColorScheme
? ["Diff colors"]
@@ -636,6 +641,9 @@ export function useSettingsRestore(onRestored?: () => void) {
}
updateSettings({
appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast,
+ timelineBackgroundImage: DEFAULT_UNIFIED_SETTINGS.timelineBackgroundImage,
+ timelineBackgroundOpacity: DEFAULT_UNIFIED_SETTINGS.timelineBackgroundOpacity,
+ timelineBackgroundBlur: DEFAULT_UNIFIED_SETTINGS.timelineBackgroundBlur,
diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme,
chatWidth: DEFAULT_UNIFIED_SETTINGS.chatWidth,
effortDisplay: DEFAULT_UNIFIED_SETTINGS.effortDisplay,
diff --git a/apps/web/src/components/settings/TimelineBackgroundSettings.tsx b/apps/web/src/components/settings/TimelineBackgroundSettings.tsx
new file mode 100644
index 0000000000000..970ecddb4ceca
--- /dev/null
+++ b/apps/web/src/components/settings/TimelineBackgroundSettings.tsx
@@ -0,0 +1,184 @@
+import { type CSSProperties, useRef, useState } from "react";
+import { IMAGE_ATTACHMENT_MAX_EDGE } from "@t3tools/client-runtime/image-attachment-size";
+import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
+import {
+ persistClientSettingsUpdate,
+ useClientSettings,
+ useUpdatePrimarySettings,
+} from "../../hooks/useSettings";
+import {
+ compressImageToByteLimit,
+ MAX_STASH_IMAGE_DATA_URL_CHARS,
+ readFileAsDataUrl,
+} from "../../lib/imageCompression";
+import { Button } from "../ui/button";
+import { SettingsRow, SettingsSection } from "./settingsLayout";
+
+// Reserve the longest prefix the accepted types produce; base64 stores 3 bytes in every 4 characters.
+const WALLPAPER_DATA_URL_PREFIX_CHARS = "data:image/jpeg;base64,".length;
+const MAX_WALLPAPER_BYTES =
+ Math.floor((MAX_STASH_IMAGE_DATA_URL_CHARS - WALLPAPER_DATA_URL_PREFIX_CHARS) / 4) * 3;
+
+/** Fits the image to the attachment edge and budget, and rejects data that cannot render. */
+async function prepareWallpaper(file: File): Promise {
+ const result = await compressImageToByteLimit(file, MAX_WALLPAPER_BYTES);
+ if (!result.ok) throw new Error("Choose a readable image no larger than 50 MB.");
+ const dataUrl = await readFileAsDataUrl(result.file);
+ const decoded = new Image();
+ decoded.src = dataUrl;
+ await decoded.decode().catch(() => {
+ throw new Error("Choose a readable image no larger than 50 MB.");
+ });
+ if (Math.max(decoded.naturalWidth, decoded.naturalHeight) > IMAGE_ATTACHMENT_MAX_EDGE) {
+ throw new Error(
+ `Choose an image no larger than ${IMAGE_ATTACHMENT_MAX_EDGE} pixels on each side.`,
+ );
+ }
+ return dataUrl;
+}
+
+/** Saves the image as the wallpaper; returns an error message, or "" on success. */
+async function saveWallpaper(file: File): Promise {
+ try {
+ const timelineBackgroundImage = await prepareWallpaper(file);
+ await persistClientSettingsUpdate((settings) => ({
+ ...settings,
+ timelineBackgroundImage,
+ }));
+ return "";
+ } catch (cause) {
+ return cause instanceof Error ? cause.message : "Could not save wallpaper.";
+ }
+}
+
+export function TimelineBackgroundSettings() {
+ const image = useClientSettings((settings) => settings.timelineBackgroundImage);
+ const opacity = useClientSettings((settings) => settings.timelineBackgroundOpacity);
+ const blur = useClientSettings((settings) => settings.timelineBackgroundBlur);
+ const updateSettings = useUpdatePrimarySettings();
+ const fileInput = useRef(null);
+ const [error, setError] = useState("");
+ const [busy, setBusy] = useState(false);
+ // Settings writes serialize the whole image, so a drag only persists once it settles.
+ const [opacityDraft, setOpacityDraft] = useState(null);
+ const [blurDraft, setBlurDraft] = useState(null);
+ const shownOpacity = opacityDraft ?? opacity;
+ const shownBlur = blurDraft ?? blur;
+ function settleOpacity() {
+ if (opacityDraft === null) return;
+ updateSettings({ timelineBackgroundOpacity: opacityDraft });
+ setOpacityDraft(null);
+ }
+ function settleBlur() {
+ if (blurDraft === null) return;
+ updateSettings({ timelineBackgroundBlur: blurDraft });
+ setBlurDraft(null);
+ }
+ async function upload(file: File) {
+ setBusy(true);
+ setError("");
+ setError(await saveWallpaper(file));
+ setBusy(false);
+ }
+ return (
+
+
+
+ {image ? (
+
+ ) : null}
+
+ }
+ />
+ {
+ const file = event.currentTarget.files?.[0];
+ event.currentTarget.value = "";
+ if (file) void upload(file);
+ }}
+ />
+ {error ? {error}
: null}
+ {image ? (
+ <>
+
0 ? `blur(${shownBlur}px)` : undefined,
+ }}
+ />
+ setOpacityDraft(Number(event.currentTarget.value))}
+ onPointerUp={settleOpacity}
+ onKeyUp={settleOpacity}
+ onBlur={settleOpacity}
+ />
+ }
+ />
+ setBlurDraft(Number(event.currentTarget.value))}
+ onPointerUp={settleBlur}
+ onKeyUp={settleBlur}
+ onBlur={settleBlur}
+ />
+ }
+ />
+ >
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts
index f7492035874da..c1df45dfa53a0 100644
--- a/apps/web/src/components/settings/settingsSearch.ts
+++ b/apps/web/src/components/settings/settingsSearch.ts
@@ -317,6 +317,12 @@ export const SETTINGS_SEARCH_ITEMS = [
searchTerms: ["appearance colors palette custom import"],
targetId: "appearance",
},
+ {
+ id: "appearance-wallpaper",
+ title: "Chat wallpaper",
+ to: "/settings/appearance",
+ searchTerms: ["background", "image", "opacity", "blur"],
+ },
{
// Prefixed because the slider control already owns the `appearance-contrast` id.
id: "setting-appearance-contrast",
diff --git a/docs/user/appearance.md b/docs/user/appearance.md
index b27173dd60ff9..c6b97bb5955a3 100644
--- a/docs/user/appearance.md
+++ b/docs/user/appearance.md
@@ -48,6 +48,10 @@ The main sidebar, right panel, and terminal drawer open and close immediately by
in your operating system. Moving between threads always snaps to the selected thread's panel state
without replaying its transitions.
+In **Settings → Appearance → Wallpaper**, upload an image for the chat background.
+Adjust opacity and blur, or choose **Remove** to restore the plain background.
+Wallpaper is saved on this device and is independent of the selected theme.
+
## Custom themes
On web and desktop, choose **Create theme** to adjust a palette, or import a T3 Code or VS Code
diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts
index a5a27235bbe7c..3b737240450b0 100644
--- a/packages/contracts/src/settings.test.ts
+++ b/packages/contracts/src/settings.test.ts
@@ -538,3 +538,31 @@ describe("server model preferences", () => {
expect(decodeServerSettingsPatch(input)).toEqual(input);
});
});
+
+describe("chat wallpaper settings", () => {
+ it("defaults older clients to no wallpaper and round-trips image controls", () => {
+ expect(decodeClientSettings({})).toMatchObject({
+ timelineBackgroundImage: "",
+ timelineBackgroundOpacity: 20,
+ timelineBackgroundBlur: 0,
+ });
+ const patch = {
+ timelineBackgroundImage: "data:image/png;base64,aGVsbG8=",
+ timelineBackgroundOpacity: 35,
+ timelineBackgroundBlur: 2,
+ };
+ expect(decodeClientSettingsPatch(patch)).toEqual(patch);
+ expect(decodeClientSettings(encodeClientSettings(decodeClientSettings(patch)))).toMatchObject(
+ patch,
+ );
+ for (const invalid of [
+ { timelineBackgroundOpacity: -1 },
+ { timelineBackgroundOpacity: 101 },
+ { timelineBackgroundOpacity: 0.5 },
+ { timelineBackgroundBlur: 31 },
+ { timelineBackgroundImage: "x".repeat(1_500_001) },
+ ]) {
+ expect(() => decodeClientSettingsPatch(invalid)).toThrow();
+ }
+ });
+});
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index cd112d84bf9f4..4db237d033226 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -152,6 +152,13 @@ export const AppearanceContrast = Schema.Int.check(
);
export type AppearanceContrast = typeof AppearanceContrast.Type;
const DEFAULT_APPEARANCE_CONTRAST: AppearanceContrast = 100;
+export const TimelineBackgroundImage = Schema.String.check(Schema.isMaxLength(1_500_000));
+export const TimelineBackgroundOpacity = Schema.Int.check(
+ Schema.isBetween({ minimum: 0, maximum: 100 }),
+);
+export const TimelineBackgroundBlur = Schema.Int.check(
+ Schema.isBetween({ minimum: 0, maximum: 30 }),
+);
export const MIN_PANEL_ANIMATION_DURATION_MS = 0;
export const MAX_PANEL_ANIMATION_DURATION_MS = 400;
export const PanelAnimationDurationMs = Schema.Int.check(
@@ -401,6 +408,15 @@ export const ClientSettingsSchema = Schema.Struct({
appearanceContrast: AppearanceContrast.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)),
),
+ timelineBackgroundImage: TimelineBackgroundImage.pipe(
+ Schema.withDecodingDefault(Effect.succeed("")),
+ ),
+ timelineBackgroundOpacity: TimelineBackgroundOpacity.pipe(
+ Schema.withDecodingDefault(Effect.succeed(20)),
+ ),
+ timelineBackgroundBlur: TimelineBackgroundBlur.pipe(
+ Schema.withDecodingDefault(Effect.succeed(0)),
+ ),
// Zero avoids per-frame layout work from width/height transitions.
panelAnimationDurationMs: PanelAnimationDurationMs.pipe(
Schema.withDecodingDefault(Effect.succeed(DEFAULT_PANEL_ANIMATION_DURATION_MS)),
@@ -1982,6 +1998,9 @@ export const ClientSettingsPatch = Schema.Struct({
loadBalancingEnabled: Schema.optionalKey(Schema.Boolean),
loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights),
appearanceContrast: Schema.optionalKey(AppearanceContrast),
+ timelineBackgroundImage: Schema.optionalKey(TimelineBackgroundImage),
+ timelineBackgroundOpacity: Schema.optionalKey(TimelineBackgroundOpacity),
+ timelineBackgroundBlur: Schema.optionalKey(TimelineBackgroundBlur),
panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs),
browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),