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 && (