diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts
index 1ab9b3159e1f9..58301aafe5aed 100644
--- a/apps/desktop/src/settings/DesktopClientSettings.test.ts
+++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts
@@ -22,6 +22,11 @@ const clientSettings: ClientSettings = {
notificationMode: "notifications-and-sound",
inAppNotificationsEnabled: true,
appearanceContrast: 100,
+ timelineBackgroundImage: "https://example.com/wallpaper.png",
+ timelineBackgroundOpacity: 47,
+ timelineBackgroundBlur: 8,
+ timelineTextShadowOpacity: 72,
+ timelineTextShadowBlur: 4,
browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
browserDefaultZoomFactor: 1.25,
browserDefaultAppearance: "dark",
diff --git a/apps/web/index.html b/apps/web/index.html
index a7a5e7450b3de..99226beacba60 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -185,6 +185,7 @@
RESERVED_THEME_IDS.includes(value.id) ||
!isThemeLabel(value.label) ||
!isThemeAppearance(value.appearance) ||
+ (value.css !== undefined && typeof value.css !== "string") ||
!isRecord(value.colors)
) {
return false;
@@ -372,6 +373,12 @@
}
}
setThemeColor(chromeColor);
+ if (effectiveCustomTheme?.css) {
+ const style = document.createElement("style");
+ style.dataset.themeCss = "";
+ style.textContent = effectiveCustomTheme.css;
+ document.head.append(style);
+ }
} catch {
let prefersDark = true;
try {
diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx
index 06960b273a057..7005b13c73cd1 100644
--- a/apps/web/src/components/ChatMarkdown.tsx
+++ b/apps/web/src/components/ChatMarkdown.tsx
@@ -195,6 +195,7 @@ import { createMarkdownRenderer } from "./chat/markdownRenderer";
import { splitMarkdownBlocks } from "./chat/markdownStreamBlocks";
import { MarkdownMermaidCodeBlock } from "./chat/MarkdownMermaidCodeBlock";
import { MarkdownTable } from "./chat/MarkdownTable";
+import { CHAT_BACKGROUND_GLASS_SURFACE_CLASSES } from "./chat/ChatTimelineBackground";
import { MarkdownDetails } from "./chat/MarkdownDetails";
import {
authoredImageSizeStyle,
@@ -227,6 +228,8 @@ const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError);
interface ChatMarkdownProps {
/** Compact markdown transforms remove blocks before rendering inside a label. */
inline?: boolean;
+ /** Enable translucent containers only when rendering over a timeline wallpaper. */
+ glassSurfaces?: boolean;
text: string;
cwd: string | undefined;
threadRef?: ScopedThreadRef | undefined;
@@ -279,6 +282,7 @@ const ARTIFACT_TEMPLATE_ICON_BY_KIND = {
function CodexArtifactTemplateCard(props: {
readonly template: CodexArtifactTemplate;
readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined;
+ readonly glass?: boolean;
}) {
const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind];
const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind);
@@ -288,7 +292,10 @@ function CodexArtifactTemplateCard(props: {
role="group"
aria-label={`${props.template.displayName} template`}
data-chat-markdown-artifact-template
- className="my-[0.65rem] flex w-full min-w-0 items-center gap-3 rounded-xl border border-border/70 bg-card/60 px-3 py-2.5 text-foreground shadow-xs"
+ className={cn(
+ "my-[0.65rem] flex w-full min-w-0 items-center gap-3 rounded-xl border border-border/70 px-3 py-2.5 text-foreground shadow-xs",
+ props.glass ? CHAT_BACKGROUND_GLASS_SURFACE_CLASSES : "bg-card/60",
+ )}
data-artifact-kind={props.template.artifactKind}
data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`}
data-skill-name={props.template.skillName}
@@ -497,6 +504,7 @@ function useChatMarkdownState({
onImageExpand,
renderContextReference,
headingLevelOffset = 0,
+ glassSurfaces = false,
sourceControlMedia = false,
sourceControlMediaRepository,
}: ChatMarkdownProps) {
@@ -899,6 +907,7 @@ function useChatMarkdownState({
sourceControlMediaRepository,
renderContextReference,
headingLevelOffset,
+ glassSurfaces,
imageBaseDir,
workspaceMutationId,
inlineCodeFileLinkMetaByText,
@@ -968,11 +977,15 @@ const CHAT_MARKDOWN_COMPONENTS = {
h5: markdownHeadingRenderer(5),
h6: markdownHeadingRenderer(6),
div: function MarkdownDiv({ node, children, ...props }) {
- const { onUseArtifactTemplate } = use(ChatMarkdownRendererContext);
+ const { onUseArtifactTemplate, glassSurfaces } = use(ChatMarkdownRendererContext);
const artifactTemplate = artifactTemplateFromHastProperties(node?.properties);
if (artifactTemplate) {
return (
-
+
);
}
return
{children}
;
@@ -1564,10 +1577,16 @@ const CHAT_MARKDOWN_COMPONENTS = {
return ;
},
table: function MarkdownTableRenderer({ node: _node, ...props }) {
- return ;
+ const { glassSurfaces } = use(ChatMarkdownRendererContext);
+ return ;
},
details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) {
- return {children};
+ const { glassSurfaces } = use(ChatMarkdownRendererContext);
+ return (
+
+ {children}
+
+ );
},
pre: function MarkdownPre({ node, children, ...props }) {
const {
@@ -1578,6 +1597,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
onRunShellCommand,
inline,
mathEnabled,
+ glassSurfaces,
} = use(ChatMarkdownRendererContext);
const text = use(ChatMarkdownTextContext);
const codeBlock = extractCodeBlock(children);
@@ -1647,6 +1667,7 @@ const CHAT_MARKDOWN_COMPONENTS = {
: undefined
}
isStreaming={isStreaming}
+ glass={glassSurfaces}
>
{highlightedCode}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 6daf189f7d6df..88c1169a4d48b 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1,4 +1,13 @@
import { ChildThreadApprovals } from "./chat/ChildThreadApprovals";
+import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
+import {
+ ChatTimelineBackground,
+ CHAT_BACKGROUND_TEXT_SHADOW_CLASSES,
+ useHasTimelineBackground,
+ useTimelineTextShadowStyle,
+} from "./chat/ChatTimelineBackground";
+import { ChatTopbarBlur } from "./chat/ChatTopbarBlur";
+import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./chat/timelineScrollAnchoring";
import { workspaceAreaProps } from "../lib/workspaceFocus";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits";
@@ -322,10 +331,7 @@ import {
latestProviderTurnTokenUsage,
shouldOfferResumeCompaction,
} from "@t3tools/client-runtime/state/context-window";
-import {
- MOBILE_COMPOSER_VIEW_TRANSITION_NAME,
- MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
-} from "./chat/draftHeroTransition";
+import { MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME } from "./chat/draftHeroTransition";
import {
buildLocalDraftThread,
deriveCommittedServerUserMessageIds,
@@ -557,6 +563,22 @@ export default function ChatView(props: ChatViewProps) {
return ids;
})();
const settings = useEnvironmentSettings(environmentId);
+ const hasTimelineBackground = useHasTimelineBackground();
+ const timelineTextShadowStyle = useTimelineTextShadowStyle();
+ const [chatHeaderElement, setChatHeaderElement] = useState(null);
+ const [chatHeaderHeight, setChatHeaderHeight] = useState(0);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the header DOM element so wallpaper rows can scroll beneath it
+ useLayoutEffect(() => {
+ if (!chatHeaderElement || !hasTimelineBackground) return;
+ return observeResize(chatHeaderElement, () =>
+ setChatHeaderHeight(chatHeaderElement.getBoundingClientRect().height),
+ );
+ }, [chatHeaderElement, hasTimelineBackground]);
+ const timelineUnderHeader = hasTimelineBackground && !embedded;
+ const timelineHeaderInset = timelineUnderHeader ? chatHeaderHeight : 0;
+ const timelineAnchorOffset =
+ (hasTimelineBackground ? CHAT_TIMELINE_ANCHOR_OFFSET : CHAT_LIST_ANCHOR_OFFSET) +
+ timelineHeaderInset;
const clientSettingsHydrated = useClientSettingsHydrated();
const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
@@ -687,6 +709,10 @@ export default function ChatView(props: ChatViewProps) {
const isMobileViewport = useMediaQuery("max-sm");
const [workspaceLayoutElement, setWorkspaceLayoutElement] = useState(null);
const threadPanelPopoverAnchorRef = useRef(null);
+ const attachChatHeader = (element: HTMLElement | null) => {
+ threadPanelPopoverAnchorRef.current = element;
+ setChatHeaderElement(element);
+ };
const [terminalUiLaunchContext, setTerminalUiLaunchContext] =
useState(null);
// Overrides chosen before a server thread's first turn; they belong to that thread only.
@@ -2103,6 +2129,7 @@ export default function ChatView(props: ChatViewProps) {
timelineEntries,
timelineAnchorMessageId,
setTimelineAnchor,
+ anchorViewOffset: timelineAnchorOffset,
});
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- schedules a frame to focus the composer DOM element when a thread opens with the terminal closed
@@ -3271,19 +3298,22 @@ export default function ChatView(props: ChatViewProps) {
{rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null}
+
+ {timelineUnderHeader ?
: null}
{embedded ? null : (
{isElectron && rightPanelControlsAtRoot ? (
@@ -3363,7 +3394,11 @@ export default function ChatView(props: ChatViewProps) {
/>
diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx
index 6b49b81401f33..e5bd78a23ec77 100644
--- a/apps/web/src/components/chat/ChangedFilesTree.tsx
+++ b/apps/web/src/components/chat/ChangedFilesTree.tsx
@@ -1,6 +1,18 @@
import { useSyncLayout } from "@legendapp/list/react";
+import {
+ CHAT_BACKGROUND_GLASS_SURFACE_CLASSES,
+ useHasTimelineBackground,
+} from "./ChatTimelineBackground";
import { type OrchestrationV2CheckpointFileTotals, type RunId } from "@t3tools/contracts";
-import { type MouseEvent, type ReactNode, memo, useLayoutEffect, useRef, useState } from "react";
+import {
+ type MouseEvent,
+ type ReactNode,
+ memo,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
import { type TurnDiffFileChange } from "../../types";
import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
import { ChevronRightIcon, FileDiffIcon } from "lucide-react";
@@ -47,17 +59,54 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
onFileContextMenu,
} = props;
const syncTimelineLayout = useSyncLayout();
+ const glass = useHasTimelineBackground();
+ const headerRef = useRef
(null);
+ const [headerPinned, setHeaderPinned] = useState(false);
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- follows the scroll container so the sticky header gets a backing only while pinned over rows
+ useEffect(() => {
+ const header = headerRef.current;
+ const card = header?.parentElement;
+ if (!glass || !header || !card) return;
+ let root = card.parentElement;
+ while (root && !/auto|scroll/.test(getComputedStyle(root).overflowY)) {
+ root = root.parentElement;
+ }
+ // One backdrop spans the card. Only a pinned header needs to cover rows beneath it.
+ let frame = 0;
+ const measure = () => {
+ frame = 0;
+ setHeaderPinned(header.getBoundingClientRect().top > card.getBoundingClientRect().top + 0.5);
+ };
+ const scheduleMeasure = () => {
+ if (!frame) frame = requestAnimationFrame(measure);
+ };
+ const scrollTarget = root ?? window;
+ scrollTarget.addEventListener("scroll", scheduleMeasure, { passive: true });
+ scheduleMeasure();
+ return () => {
+ scrollTarget.removeEventListener("scroll", scheduleMeasure);
+ cancelAnimationFrame(frame);
+ };
+ });
const hiddenFileCount = totals.count - files.length;
const hasDirectories = files.some((file) => /[/\\]/.test(file.path));
return (
@@ -108,7 +157,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
-
+
+
+
{hiddenFileCount > 0 && (