Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -3371,8 +3372,9 @@ export default function ChatView(props: ChatViewProps) {
/>
<div
{...conversationArea}
className="relative flex min-h-0 flex-1 flex-col bg-background"
className="relative isolate flex min-h-0 flex-1 flex-col bg-background"
>
<ChatTimelineBackground />
<WorkspaceMutationIdContext value={workspaceMutationId}>
<ThreadTimeline
pendingApprovals={pendingApprovals}
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/components/chat/ChatTimelineBackground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useClientSettings } from "../../hooks/useSettings";

export function ChatTimelineBackground() {
const image = useClientSettings((settings) => settings.timelineBackgroundImage);
const opacity = useClientSettings((settings) => settings.timelineBackgroundOpacity);
const blur = useClientSettings((settings) => settings.timelineBackgroundBlur);
if (!image || opacity === 0) return null;
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<img
key={image}
src={image}
alt=""
draggable={false}
onError={(event) => {
event.currentTarget.style.visibility = "hidden";
}}
className="absolute inset-0 size-full object-cover"
style={{ opacity: opacity / 100, filter: blur > 0 ? `blur(${blur}px)` : undefined }}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1263,7 +1263,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
return (
<>
{activityAnnouncer}
<div className="h-full min-h-0 bg-background" data-timeline-loading="true" />
<div className="h-full min-h-0" data-timeline-loading="true" />
</>
);
}
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/AppearanceSettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TimelineBackgroundSettings } from "./TimelineBackgroundSettings";
import type { CSSProperties } from "react";
import { useState } from "react";
import {
Expand Down Expand Up @@ -162,6 +163,8 @@ export function AppearanceSettingsPanel() {
</div>
</SettingsSection>

<TimelineBackgroundSettings />

<SettingsSection id="appearance-interface" title="Interface">
<SettingsRow
{...searchableSetting("setting-appearance-contrast")}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,11 @@ export function useSettingsRestore(onRestored?: () => 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"]
Expand Down Expand Up @@ -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,
Expand Down
184 changes: 184 additions & 0 deletions apps/web/src/components/settings/TimelineBackgroundSettings.tsx
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<HTMLInputElement>(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<number | null>(null);
const [blurDraft, setBlurDraft] = useState<number | null>(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 (
<SettingsSection id="appearance-wallpaper" title="Wallpaper">
<SettingsRow
title="Chat wallpaper"
description="Choose an image for the conversation background."
control={
<div className="flex gap-2">
<Button variant="outline" disabled={busy} onClick={() => fileInput.current?.click()}>
{busy ? "Saving…" : "Choose image"}
</Button>
{image ? (
<Button
variant="outline"
disabled={busy}
onClick={() =>
updateSettings({
timelineBackgroundImage: "",
timelineBackgroundOpacity: DEFAULT_CLIENT_SETTINGS.timelineBackgroundOpacity,
timelineBackgroundBlur: 0,
})
}
>
Remove
</Button>
) : null}
</div>
}
/>
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void upload(file);
}}
/>
{error ? <p role="alert">{error}</p> : null}
{image ? (
<>
<img
src={image}
alt="Current wallpaper"
className="max-h-48 w-full rounded-lg object-cover"
style={{
opacity: shownOpacity / 100,
filter: shownBlur > 0 ? `blur(${shownBlur}px)` : undefined,
}}
/>
<SettingsRow
title="Opacity"
control={
<input
aria-label="Wallpaper opacity"
className="settings-slider"
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
type="range"
min={0}
max={100}
value={shownOpacity}
style={
{
"--settings-slider-progress": `${shownOpacity}%`,
"--settings-slider-fill-offset": `${0.5 - shownOpacity / 100}rem`,
} as CSSProperties
}
onChange={(event) => setOpacityDraft(Number(event.currentTarget.value))}
onPointerUp={settleOpacity}
onKeyUp={settleOpacity}
onBlur={settleOpacity}
/>
}
/>
<SettingsRow
title="Blur"
control={
<input
aria-label="Wallpaper blur"
className="settings-slider"
type="range"
min={0}
max={30}
value={shownBlur}
style={
{
"--settings-slider-progress": `${(shownBlur / 30) * 100}%`,
"--settings-slider-fill-offset": `${0.5 - shownBlur / 30}rem`,
} as CSSProperties
}
onChange={(event) => setBlurDraft(Number(event.currentTarget.value))}
onPointerUp={settleBlur}
onKeyUp={settleBlur}
onBlur={settleBlur}
/>
}
/>
</>
) : null}
</SettingsSection>
);
}
6 changes: 6 additions & 0 deletions apps/web/src/components/settings/settingsSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions docs/user/appearance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/contracts/src/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
});
19 changes: 19 additions & 0 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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),
Expand Down
Loading