diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000000..78c591e4d8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +## [Unreleased] + +### Added +- **Theme selector**: Added theme toggle button in sidebar with three theme options: + - **Light**: Light mode + - **Dark**: Balanced dark mode with brighter backgrounds and more visible UI elements + - **Night**: Darkest mode (previously the only dark mode) + - **System**: Follow system preference + +### Changed +- **apps/web/src/hooks/useTheme.ts**: Extended theme system to support `light`, `dark`, `night`, and `system` themes. Added `THEME_OPTIONS` export and `Theme` type export. +- **apps/web/src/index.css**: Added CSS variables for the new "dark" middle-ground theme and renamed existing dark theme to "night". The dark theme now uses brighter backgrounds (neutral-800/900 vs neutral-950) and more visible borders/accents. +- **apps/web/src/components/Sidebar.tsx**: Added theme selector menu with icon (Sun/Moon/MoonStar) in the sidebar header, available in both desktop and web modes. +- **apps/web/src/components/ChatView.tsx**: Made user messages more compact: + - Reduced padding (px-3 py-2 instead of px-4 py-3) + - Smaller text (13px instead of 14px) + - Action buttons (copy, rollback) and timestamp moved below the message card + - Actions only appear on hover for cleaner UI diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c1693f41997..a9340c534ec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5047,10 +5047,10 @@ const MessagesTimeline = memo(function MessagesTimeline({ const userImages = row.message.attachments ?? []; const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); return ( -
-
+
+
{userImages.length > 0 && ( -
+
{userImages.map( (image: NonNullable[number]) => (
)} {row.message.text && ( -
+                  
                     {row.message.text}
                   
)} -
-
- {row.message.text && } - {canRevertAgentWork && ( - - )} -
-

- {formatTimestamp(row.message.createdAt)} -

-
+
+
+ {row.message.text && } + {canRevertAgentWork && ( + + )} + + {formatTimestamp(row.message.createdAt)} +
); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index e3a3ce02efb..b4adc6e95d1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2,8 +2,11 @@ import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, + MoonIcon, + MoonStarIcon, RocketIcon, SquarePenIcon, + SunIcon, TerminalIcon, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -60,6 +63,14 @@ import { } from "./ui/sidebar"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; +import { useTheme, THEME_OPTIONS, type Theme } from "../hooks/useTheme"; +import { + Menu, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuTrigger, +} from "./ui/menu"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; @@ -257,7 +268,14 @@ function ProjectFavicon({ cwd }: { cwd: string }) { ); } +function ThemeIcon({ theme }: { theme: Theme }) { + if (theme === "light") return ; + if (theme === "night") return ; + return ; +} + export default function Sidebar() { + const { theme, setTheme } = useTheme(); const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); const markThreadUnread = useStore((store) => store.markThreadUnread); @@ -990,36 +1008,79 @@ export default function Sidebar() {
); + const themeSelector = ( + + + + + + } + /> + } + /> + Theme + + + setTheme(value as Theme)} + > + {THEME_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ); + return ( <> {isElectron ? ( <> - + {wordmark} - {showDesktopUpdateButton && ( - - - - - } - /> - {desktopUpdateTooltip} - - )} +
+ {themeSelector} + {showDesktopUpdateButton && ( + + + + + } + /> + {desktopUpdateTooltip} + + )} +
) : ( - {wordmark} +
+ {wordmark} +
+ {themeSelector} +
+
)} diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 1e6a7cc8802..715dfc8313d 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,11 +1,18 @@ import { useCallback, useEffect, useSyncExternalStore } from "react"; -type Theme = "light" | "dark" | "system"; +type Theme = "light" | "dark" | "night" | "system"; type ThemeSnapshot = { theme: Theme; systemDark: boolean; }; +export const THEME_OPTIONS = [ + { value: "light", label: "Light", description: "Light mode" }, + { value: "dark", label: "Dark", description: "Balanced dark mode" }, + { value: "night", label: "Night", description: "Darkest mode" }, + { value: "system", label: "System", description: "Follow system preference" }, +] as const; + const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; @@ -21,7 +28,7 @@ function getSystemDark(): boolean { function getStored(): Theme { const raw = localStorage.getItem(STORAGE_KEY); - if (raw === "light" || raw === "dark" || raw === "system") return raw; + if (raw === "light" || raw === "dark" || raw === "night" || raw === "system") return raw; return "system"; } @@ -29,8 +36,22 @@ function applyTheme(theme: Theme, suppressTransitions = false) { if (suppressTransitions) { document.documentElement.classList.add("no-transitions"); } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); - document.documentElement.classList.toggle("dark", isDark); + + // Determine which CSS classes to apply + // - "dark" class = dark mode (middle ground) + // - "night" class = darkest mode + // - neither = light mode + const resolvedTheme = theme === "system" + ? (getSystemDark() ? "dark" : "light") + : theme; + + document.documentElement.classList.remove("dark", "night"); + if (resolvedTheme === "dark") { + document.documentElement.classList.add("dark"); + } else if (resolvedTheme === "night") { + document.documentElement.classList.add("dark", "night"); + } + if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal // oxlint-disable-next-line no-unused-expressions @@ -87,8 +108,14 @@ export function useTheme() { const snapshot = useSyncExternalStore(subscribe, getSnapshot); const theme = snapshot.theme; + // resolvedTheme returns "light" or "dark" for components that only care about light/dark distinction + // (night is treated as dark for icon/color purposes) const resolvedTheme: "light" | "dark" = - theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; + theme === "system" + ? (snapshot.systemDark ? "dark" : "light") + : theme === "night" + ? "dark" + : theme; const setTheme = useCallback((next: Theme) => { localStorage.setItem(STORAGE_KEY, next); @@ -103,3 +130,5 @@ export function useTheme() { return { theme, setTheme, resolvedTheme } as const; } + +export type { Theme }; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 22817916197..f962a66551c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); +@custom-variant night (&:is(.night, .night *)); @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; @@ -90,13 +91,44 @@ --warning: var(--color-amber-500); --warning-foreground: var(--color-amber-700); + /* Dark mode - balanced dark, between light and night */ @variant dark { + color-scheme: dark; + --background: color-mix(in srgb, var(--color-neutral-900) 97%, var(--color-black)); + --foreground: var(--color-neutral-100); + --card: color-mix(in srgb, var(--color-neutral-900) 92%, var(--color-white)); + --card-foreground: var(--color-neutral-100); + --popover: color-mix(in srgb, var(--color-neutral-900) 94%, var(--color-white)); + --popover-foreground: var(--color-neutral-100); + --primary: oklch(0.588 0.217 264); + --primary-foreground: var(--color-white); + --secondary: --alpha(var(--color-white) / 5%); + --secondary-foreground: var(--color-neutral-100); + --muted: --alpha(var(--color-white) / 6%); + --muted-foreground: color-mix(in srgb, var(--color-neutral-400) 90%, var(--color-white)); + --accent: --alpha(var(--color-white) / 6%); + --accent-foreground: var(--color-neutral-100); + --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); + --border: --alpha(var(--color-white) / 8%); + --input: --alpha(var(--color-white) / 10%); + --ring: oklch(0.588 0.217 264); + --destructive-foreground: var(--color-red-400); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-400); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-400); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-400); + } + + /* Night mode - darkest */ + @variant night { color-scheme: dark; --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); --foreground: var(--color-neutral-100); - --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --card: color-mix(in srgb, var(--color-neutral-950) 98%, var(--color-white)); --card-foreground: var(--color-neutral-100); - --popover: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --popover: color-mix(in srgb, var(--color-neutral-950) 98%, var(--color-white)); --popover-foreground: var(--color-neutral-100); --primary: oklch(0.588 0.217 264); --primary-foreground: var(--color-white);