diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 662a17f4e223..81a7a75bc4ff 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -50,7 +50,7 @@ if (clientPlatform === "darwin") { const syncWindowControlInset = () => { document.documentElement.style.setProperty( "--desktop-window-controls-inset", - `${90 / webFrame.getZoomFactor()}px`, + `${82 / webFrame.getZoomFactor()}px`, ); }; window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true }); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index c7cabf46c1fb..f8e63c08eca0 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -797,10 +797,10 @@ describe("DesktopWindow", () => { yield* desktopWindow.zoomMain(direction); const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0]; assert.isDefined(position); - // The 14-point native buttons should share the zoomed 52px header's center. - const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor(); + // The 14-point native buttons should share the zoomed 42px header's center. + const headerCenter = 21 * fakeWindow.window.webContents.getZoomFactor(); assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5); - assert.equal(position.x, 16); + assert.equal(position.x, 14); } fakeWindow.isFullScreen.mockReturnValue(true); @@ -810,7 +810,7 @@ describe("DesktopWindow", () => { fakeWindow.isFullScreen.mockReturnValue(false); fakeWindow.windowListeners.get("leave-full-screen")?.(); - assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]); + assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 14, y: 14 }]); }).pipe(Effect.provide(layer)); }), ); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index fa8e8a9af901..d64bf3fcf358 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -34,15 +34,17 @@ import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts" import { makeQuitShortcutHandler } from "./QuitHold.ts"; const TITLEBAR_HEIGHT = 40; -// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS -// buttons are 14 points tall and do not scale with the renderer's zoom. -const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52; +// Matches the Mac app's --workspace-topbar-height in apps/web/src/appFrame.css. +// Native macOS buttons are 14 points tall and do not scale with the renderer's zoom. +const MACOS_WORKSPACE_TOPBAR_HEIGHT = 42; const MACOS_WINDOW_BUTTON_RADIUS = 7; +// The buttons' left inset; the renderer leaves them 82px (preload.ts). +const MACOS_WINDOW_BUTTON_X = 14; function syncMacosWindowButtons(window: Electron.BrowserWindow): void { if (window.isDestroyed() || window.isFullScreen()) return; window.setWindowButtonPosition({ - x: 16, + x: MACOS_WINDOW_BUTTON_X, y: Math.round( (MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 - MACOS_WINDOW_BUTTON_RADIUS, @@ -262,7 +264,7 @@ function getWindowTitleBarOptions( return { titleBarStyle: "hiddenInset", trafficLightPosition: { - x: 16, + x: MACOS_WINDOW_BUTTON_X, y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS, }, }; diff --git a/apps/mobile/generated-uniwind-theme-names.json b/apps/mobile/generated-uniwind-theme-names.json index 4ec9f01f8ee7..35fe965883d2 100644 --- a/apps/mobile/generated-uniwind-theme-names.json +++ b/apps/mobile/generated-uniwind-theme-names.json @@ -1,4 +1,6 @@ [ + "chatgpt-light", + "chatgpt-dark", "t3-chat-light", "t3-chat-dark", "grove-light", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index bd0f69b5c024..60aabcbeead9 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -309,6 +309,314 @@ --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); } + @variant chatgpt-light { + --color-screen: #ffffff; + --color-sheet: rgba(249, 249, 249, 0.98); + --color-sheet-solid: #f9f9f9; + --color-card: #ffffff; + --color-grouped-card: #ffffff; + --color-card-alt: #f4f4f4; + --color-card-translucent: rgba(255, 255, 255, 0.8); + --color-thread-canvas: #ffffff; + --color-thread-selected: #e8e8e8; + --color-thread-selected-foreground: #0d0d0d; + --color-thread-selected-foreground-muted: #686868; + --color-thread-hover: #efefef; + --color-row-hover: #f0f0f0; + --color-composer-panel: rgba(255, 255, 255, 0.88); + --color-composer-surface: rgba(255, 255, 255, 0.94); + --color-composer-border: rgba(232, 232, 232, 0.54); + --color-foreground: #0d0d0d; + --color-foreground-secondary: #5d5d5d; + --color-foreground-muted: #5d5d5d; + --color-foreground-tertiary: #5d5d5d; + --color-border: #e8e8e8; + --color-focus: #0285ff; + --color-border-subtle: rgba(232, 232, 232, 0.7); + --color-separator: rgba(232, 232, 232, 0.55); + --color-subtle: #f7f7f7; + --color-subtle-strong: #f4f4f4; + --color-inline-skill-background: #f0f0f0; + --color-inline-skill-border: rgba(2, 133, 255, 0.42); + --color-inline-skill-foreground: #0d0d0d; + --color-primary: #0d0d0d; + --color-primary-foreground: #ffffff; + --color-primary-text: #0d0d0d; + --color-primary-shadow: #000000; + --color-secondary: #f4f4f4; + --color-secondary-foreground: #0d0d0d; + --color-secondary-border: #e8e8e8; + --color-switch-active-track: #0d0d0d; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: #f4f4f4; + --color-switch-inactive-thumb: #5d5d5d; + --color-warning: #fdf4e6; + --color-warning-border: rgba(227, 140, 0, 0.32); + --color-warning-foreground: #af5800; + --color-danger: #fdecec; + --color-danger-border: rgba(224, 46, 42, 0.32); + --color-danger-foreground: #c4201c; + --color-update: #e6f2ff; + --color-update-foreground: #0169cc; + --color-input: #ffffff; + --color-input-border: #d9d9d9; + --color-sidebar-search: #ececec; + --color-placeholder: #707070; + --color-icon: #0d0d0d; + --color-icon-muted: #7a7a7a; + --color-icon-subtle: #5d5d5d; + --color-header: rgba(255, 255, 255, 0.97); + --color-header-foreground: #0d0d0d; + --color-header-border: #e8e8e8; + --color-glass-surface: rgba(255, 255, 255, 0.74); + --color-glass-fallback: rgba(255, 255, 255, 0.94); + --color-glass-tint: rgba(255, 255, 255, 0.22); + --color-status-bar: #ffffff; + --color-md-body: #0d0d0d; + --color-md-strong: #0d0d0d; + --color-md-link: #0d0d0d; + --color-md-blockquote-border: #e8e8e8; + --color-md-blockquote-bg: #f7f7f7; + --color-md-code-bg: #f4f4f4; + --color-md-code-text: #0d0d0d; + --color-md-user-code-bg: rgba(13, 13, 13, 0.18); + --color-md-user-code-text: #0d0d0d; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #0d0d0d; + --color-md-hr: #e8e8e8; + --color-user-bubble: #f4f4f4; + --color-user-bubble-foreground: #0d0d0d; + --color-user-bubble-foreground-muted: rgba(13, 13, 13, 0.78); + --color-user-bubble-skill-foreground: #026fd5; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: #f9f9f9; + --color-drawer-foreground: #0d0d0d; + --color-drawer-foreground-muted: #6c6c6c; + --color-drawer-border: #e8e8e8; + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(93, 93, 93, 0.35); + --color-wordmark: #0d0d0d; + --color-chevron: rgba(93, 93, 93, 0.42); + --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); + --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); + --color-adaptive-amber-500-a10-400-a10: oklch(76.9% 0.188 70.08 / 10%); + --color-adaptive-amber-500-a25-400-a25: oklch(76.9% 0.188 70.08 / 25%); + --color-adaptive-amber-700-300: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-700-400: oklch(55.5% 0.163 48.998); + --color-adaptive-amber-800-200: oklch(47.3% 0.137 46.201); + --color-adaptive-blue-50-blue-400-a14: oklch(97% 0.014 254.604); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(80.9% 0.105 251.813 / 50%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(62.3% 0.214 259.815 / 20%); + --color-adaptive-blue-500-400: oklch(62.3% 0.214 259.815); + --color-adaptive-blue-600-400: oklch(54.6% 0.245 262.881); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 10%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 15%); + --color-adaptive-black-a2p5-white-a2p5: rgb(0 0 0 / 2.5%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 12%); + --color-adaptive-emerald-600-400: oklch(59.6% 0.145 163.225); + --color-adaptive-emerald-700-300: oklch(50.8% 0.118 165.612); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 12%); + --color-adaptive-indigo-600-300: oklch(51.1% 0.262 276.966); + --color-adaptive-indigo-700-300: oklch(45.7% 0.24 277.023); + --color-adaptive-neutral-100-900: oklch(97% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(92.2% 0 none); + --color-adaptive-neutral-200-800: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a70-white-a8: oklch(92.2% 0 none / 70%); + --color-adaptive-neutral-200-white-a6: oklch(92.2% 0 none); + --color-adaptive-neutral-200-white-a8: oklch(92.2% 0 none); + --color-adaptive-neutral-200-a80-white-a8: oklch(92.2% 0 none / 80%); + --color-adaptive-neutral-300-a60-white-a12: oklch(87% 0 none / 60%); + --color-adaptive-neutral-400-500: oklch(70.8% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(70.8% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(70.8% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 10%); + --color-adaptive-neutral-500-400: oklch(55.6% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(43.9% 0 none); + --color-adaptive-neutral-600-400: oklch(43.9% 0 none); + --color-adaptive-neutral-950-50: oklch(14.5% 0 none); + --color-adaptive-neutral-950-a5-white-a5: oklch(14.5% 0 none / 5%); + --color-adaptive-neutral-950-a10-white-a10: oklch(14.5% 0 none / 10%); + --color-adaptive-red-50-950-a80: oklch(97.1% 0.013 17.38); + --color-adaptive-red-200-800: oklch(88.5% 0.062 18.334); + --color-adaptive-red-600-a80-400-a80: oklch(57.7% 0.245 27.325 / 80%); + --color-adaptive-red-700-300: oklch(50.5% 0.213 27.518); + --color-adaptive-rose-100-500-a18: oklch(94.1% 0.03 12.58); + --color-adaptive-rose-100-a80-500-a12: oklch(94.1% 0.03 12.58 / 80%); + --color-adaptive-rose-300-a70-400-a28: oklch(81% 0.117 11.638 / 70%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-500-400: oklch(64.5% 0.246 16.439); + --color-adaptive-rose-600-400: oklch(58.6% 0.253 17.585); + --color-adaptive-rose-700-300: oklch(51.4% 0.222 16.935); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 12%); + --color-adaptive-sky-500-a10-400-a10: oklch(68.5% 0.169 237.323 / 10%); + --color-adaptive-sky-500-a25-400-a25: oklch(68.5% 0.169 237.323 / 25%); + --color-adaptive-sky-600-400: oklch(58.8% 0.158 241.966); + --color-adaptive-sky-700-300: oklch(50% 0.134 242.749); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 12%); + --color-adaptive-violet-600-400: oklch(54.1% 0.281 293.009); + --color-adaptive-violet-700-300: oklch(49.1% 0.27 292.581); + --color-adaptive-white-neutral-950-a70: #fff; + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); + --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); + --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); + } + + @variant chatgpt-dark { + --color-screen: #181818; + --color-sheet: rgba(32, 32, 32, 0.98); + --color-sheet-solid: #202020; + --color-card: #1c1c1c; + --color-grouped-card: #1c1c1c; + --color-card-alt: #303030; + --color-card-translucent: rgba(28, 28, 28, 0.8); + --color-thread-canvas: #181818; + --color-thread-selected: #333333; + --color-thread-selected-foreground: #ececec; + --color-thread-selected-foreground-muted: #9b9b9b; + --color-thread-hover: #292929; + --color-row-hover: #2e2e2e; + --color-composer-panel: rgba(24, 24, 24, 0.92); + --color-composer-surface: rgba(28, 28, 28, 0.9); + --color-composer-border: rgba(44, 44, 44, 0.46); + --color-foreground: #ececec; + --color-foreground-secondary: #a3a3a3; + --color-foreground-muted: #a3a3a3; + --color-foreground-tertiary: #a3a3a3; + --color-border: #2c2c2c; + --color-focus: #339cff; + --color-border-subtle: rgba(44, 44, 44, 0.7); + --color-separator: rgba(44, 44, 44, 0.55); + --color-subtle: #1f1f1f; + --color-subtle-strong: #232323; + --color-inline-skill-background: #262626; + --color-inline-skill-border: rgba(51, 156, 255, 0.42); + --color-inline-skill-foreground: #ececec; + --color-primary: #ffffff; + --color-primary-foreground: #0d0d0d; + --color-primary-text: #ffffff; + --color-primary-shadow: #000000; + --color-secondary: #232323; + --color-secondary-foreground: #ececec; + --color-secondary-border: #2c2c2c; + --color-switch-active-track: #ffffff; + --color-switch-active-thumb: #0d0d0d; + --color-switch-inactive-track: #232323; + --color-switch-inactive-thumb: #a3a3a3; + --color-warning: #35291a; + --color-warning-border: rgba(245, 165, 36, 0.32); + --color-warning-foreground: #ffc266; + --color-danger: #3a1f1f; + --color-danger-border: rgba(255, 107, 107, 0.32); + --color-danger-foreground: #ff8a8a; + --color-update: #1a2b3d; + --color-update-foreground: #66b5ff; + --color-input: #1c1c1c; + --color-input-border: #3a3a3a; + --color-sidebar-search: #2e2e2e; + --color-placeholder: #979797; + --color-icon: #ececec; + --color-icon-muted: #9a9a9a; + --color-icon-subtle: #a3a3a3; + --color-header: rgba(24, 24, 24, 0.97); + --color-header-foreground: #ececec; + --color-header-border: #2a2a2a; + --color-glass-surface: rgba(45, 45, 45, 0.74); + --color-glass-fallback: rgba(28, 28, 28, 0.9); + --color-glass-tint: rgba(45, 45, 45, 0.22); + --color-status-bar: #181818; + --color-md-body: #ececec; + --color-md-strong: #ececec; + --color-md-link: #ffffff; + --color-md-blockquote-border: #2c2c2c; + --color-md-blockquote-bg: #1f1f1f; + --color-md-code-bg: #232323; + --color-md-code-text: #ececec; + --color-md-user-code-bg: rgba(236, 236, 236, 0.18); + --color-md-user-code-text: #ececec; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #ececec; + --color-md-hr: #2c2c2c; + --color-user-bubble: #2f2f2f; + --color-user-bubble-foreground: #ececec; + --color-user-bubble-foreground-muted: rgba(236, 236, 236, 0.78); + --color-user-bubble-skill-foreground: #ffffff; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: #202020; + --color-drawer-foreground: #ececec; + --color-drawer-foreground-muted: #9a9a9a; + --color-drawer-border: #2a2a2a; + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(163, 163, 163, 0.35); + --color-wordmark: #ececec; + --color-chevron: rgba(163, 163, 163, 0.42); + --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); + --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); + --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); + --color-adaptive-amber-500-a10-400-a10: oklch(82.8% 0.189 84.429 / 10%); + --color-adaptive-amber-500-a25-400-a25: oklch(82.8% 0.189 84.429 / 25%); + --color-adaptive-amber-700-300: oklch(87.9% 0.169 91.605); + --color-adaptive-amber-700-400: oklch(82.8% 0.189 84.429); + --color-adaptive-amber-800-200: oklch(92.4% 0.12 95.746); + --color-adaptive-blue-50-blue-400-a14: oklch(70.7% 0.165 254.624 / 14%); + --color-adaptive-blue-300-a50-blue-400-a28: oklch(70.7% 0.165 254.624 / 28%); + --color-adaptive-blue-500-a20-blue-400-a15: oklch(70.7% 0.165 254.624 / 15%); + --color-adaptive-blue-500-400: oklch(70.7% 0.165 254.624); + --color-adaptive-blue-600-400: oklch(70.7% 0.165 254.624); + --color-adaptive-black-a10-a25: rgb(0 0 0 / 25%); + --color-adaptive-black-a15-a35: rgb(0 0 0 / 35%); + --color-adaptive-black-a2p5-white-a2p5: rgb(255 255 255 / 2.5%); + --color-adaptive-emerald-500-a12-a16: oklch(69.6% 0.17 162.48 / 16%); + --color-adaptive-emerald-600-400: oklch(76.5% 0.177 163.223); + --color-adaptive-emerald-700-300: oklch(84.5% 0.143 164.978); + --color-adaptive-indigo-500-a12-a16: oklch(58.5% 0.233 277.117 / 16%); + --color-adaptive-indigo-600-300: oklch(78.5% 0.115 274.713); + --color-adaptive-indigo-700-300: oklch(78.5% 0.115 274.713); + --color-adaptive-neutral-100-900: oklch(20.5% 0 none); + --color-adaptive-neutral-200-700-a60: oklch(37.1% 0 none / 60%); + --color-adaptive-neutral-200-800: oklch(26.9% 0 none); + --color-adaptive-neutral-200-a70-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-white-a6: rgb(255 255 255 / 6%); + --color-adaptive-neutral-200-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-200-a80-white-a8: rgb(255 255 255 / 8%); + --color-adaptive-neutral-300-a60-white-a12: rgb(255 255 255 / 12%); + --color-adaptive-neutral-400-500: oklch(55.6% 0 none); + --color-adaptive-neutral-400-a60-500-a60: oklch(55.6% 0 none / 60%); + --color-adaptive-neutral-400-a80-500-a80: oklch(55.6% 0 none / 80%); + --color-adaptive-neutral-500-a10-a16: oklch(55.6% 0 none / 16%); + --color-adaptive-neutral-500-400: oklch(70.8% 0 none); + --color-adaptive-neutral-500-500: oklch(55.6% 0 none); + --color-adaptive-neutral-600-300: oklch(87% 0 none); + --color-adaptive-neutral-600-400: oklch(70.8% 0 none); + --color-adaptive-neutral-950-50: oklch(98.5% 0 none); + --color-adaptive-neutral-950-a5-white-a5: rgb(255 255 255 / 5%); + --color-adaptive-neutral-950-a10-white-a10: rgb(255 255 255 / 10%); + --color-adaptive-red-50-950-a80: oklch(25.8% 0.092 26.042 / 80%); + --color-adaptive-red-200-800: oklch(44.4% 0.177 26.899); + --color-adaptive-red-600-a80-400-a80: oklch(70.4% 0.191 22.216 / 80%); + --color-adaptive-red-700-300: oklch(80.8% 0.114 19.571); + --color-adaptive-rose-100-500-a18: oklch(64.5% 0.246 16.439 / 18%); + --color-adaptive-rose-100-a80-500-a12: oklch(64.5% 0.246 16.439 / 12%); + --color-adaptive-rose-300-a70-400-a28: oklch(71.2% 0.194 13.428 / 28%); + --color-adaptive-rose-500-a12-a16: oklch(64.5% 0.246 16.439 / 16%); + --color-adaptive-rose-500-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-600-400: oklch(71.2% 0.194 13.428); + --color-adaptive-rose-700-300: oklch(81% 0.117 11.638); + --color-adaptive-sky-500-a12-a16: oklch(68.5% 0.169 237.323 / 16%); + --color-adaptive-sky-500-a10-400-a10: oklch(74.6% 0.16 232.661 / 10%); + --color-adaptive-sky-500-a25-400-a25: oklch(74.6% 0.16 232.661 / 25%); + --color-adaptive-sky-600-400: oklch(74.6% 0.16 232.661); + --color-adaptive-sky-700-300: oklch(82.8% 0.111 230.318); + --color-adaptive-violet-500-a12-a16: oklch(60.6% 0.25 292.717 / 16%); + --color-adaptive-violet-600-400: oklch(70.2% 0.183 293.541); + --color-adaptive-violet-700-300: oklch(81.1% 0.111 293.571); + --color-adaptive-white-neutral-950-a70: oklch(14.5% 0 none / 70%); + --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); + --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); + --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); + } + @variant t3-chat-light { --color-screen: #fdf7fd; --color-sheet: rgba(253, 247, 253, 0.98); diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts index e03a1406cfcf..c93a2e61906b 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.test.ts +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -27,6 +27,8 @@ describe("generate mobile Uniwind themes", () => { it("registers every custom palette for both appearances", () => { expect(customThemeNames).toEqual([ + "chatgpt-light", + "chatgpt-dark", "t3-chat-light", "t3-chat-dark", "grove-light", diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 0ebf6228de6f..18dad8fb61ce 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -243,6 +243,13 @@ export function createMobileThemeVariables( const c = nativeColors(colors); const groupedCard = themeColorToNativeColor(groupedCardColor); const textSurfaces = [c.canvas, c.surface, c.surfaceRaised, c.chrome, groupedCard]; + // Skills in a sent message wear the send color; a monochrome theme's send + // color is its message text (ChatGPT's), so there they take its accent. + const actionOnBubble = readableTextColor(c.messageAction, c.messageSurface); + const skillOnBubble = + actionOnBubble === c.messageForeground + ? readableTextColor(c.accent, c.messageSurface) + : actionOnBubble; return { "--color-screen": c.canvas, "--color-sheet": withAlpha(c.chrome, 0.98), @@ -330,7 +337,7 @@ export function createMobileThemeVariables( "--color-user-bubble": c.messageSurface, "--color-user-bubble-foreground": c.messageForeground, "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78), - "--color-user-bubble-skill-foreground": readableTextColor(c.messageAction, c.messageSurface), + "--color-user-bubble-skill-foreground": skillOnBubble, "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22), "--color-drawer": c.sidebar, "--color-drawer-foreground": c.sidebarForeground, diff --git a/apps/mobile/src/lib/mobileThemeRuntime.test.ts b/apps/mobile/src/lib/mobileThemeRuntime.test.ts index ad678d1f8815..22b8ebefd5b6 100644 --- a/apps/mobile/src/lib/mobileThemeRuntime.test.ts +++ b/apps/mobile/src/lib/mobileThemeRuntime.test.ts @@ -28,7 +28,7 @@ describe("mobileThemeRuntime", () => { (operation) => operation.kind === "update-text-variables", ); - expect(variableOperations).toHaveLength(12); + expect(variableOperations).toHaveLength(14); expect(variableOperations.at(-1)?.themeName).toBe("iris-dark"); expect(operations.at(-1)).toEqual({ kind: "set-appearance-mode", @@ -68,7 +68,7 @@ describe("mobileThemeRuntime", () => { baseFontSize: 18, }); - expect(operations).toHaveLength(12); + expect(operations).toHaveLength(14); expect(operations.every((operation) => operation.kind === "update-text-variables")).toBe(true); expect(operations.at(-1)).toMatchObject({ kind: "update-text-variables", diff --git a/apps/mobile/uniwind-types.d.ts b/apps/mobile/uniwind-types.d.ts index 22856ab57ffa..b915a4933d24 100644 --- a/apps/mobile/uniwind-types.d.ts +++ b/apps/mobile/uniwind-types.d.ts @@ -3,7 +3,7 @@ declare module 'uniwind' { export interface UniwindConfig { - themes: readonly ['light', 'dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark'] + themes: readonly ['light', 'dark', 'chatgpt-light', 'chatgpt-dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark'] } } diff --git a/apps/web/src/appFrame.css b/apps/web/src/appFrame.css new file mode 100644 index 000000000000..05269e1bb95f --- /dev/null +++ b/apps/web/src/appFrame.css @@ -0,0 +1,206 @@ +/* + * Otter Mail's window frame (AppSidebarLayout sets data-app-frame on the + * sidebar wrapper, from md up). The window wears the sidebar's surface: the + * rail of spaces runs down its left edge, and the sidebar and panes after it + * share one rounded content panel. In the desktop app ("window") the panel + * starts under the title band, so every pane's header sits on the frame, with + * a thin margin of frame on the right and bottom. In a browser tab ("tab") + * the panel runs to the page's edges, rounded only against the rail. + * + * The panes keep painting their own surfaces; the frame only recolors their + * title bands, and AppFramePanelEdge draws the rim and rounds the corners + * over them, so no pane has to know where the panel's corners fall. + */ + +:root { + --workspace-rail-width: 52px; + /* The window's frame: a step off the canvas, as Otter Mail's Mac window + reads over its vibrancy, so the panel inside it has an edge. Themes use + their sidebar color. Resolved on the root so it is one color everywhere. */ + --app-frame: #f4f4f5; + /* The panel's edge against the frame: the frame a little of the way to the + text, so it shows in every theme whatever its border role is. */ + --panel-edge: color-mix(in srgb, var(--foreground) 5%, var(--app-frame)); + /* The sidebar's body in the panel: halfway from the frame to the canvas, so + the rail stands apart from it. */ + --sidebar-panel-surface: color-mix(in srgb, var(--app-frame) 50%, var(--background)); + --app-frame-radius: calc(var(--radius) + 4px); + + @variant dark { + --app-frame: #171717; + } +} + +html[data-theme-id]:not([data-theme-id=""]) { + --app-frame: var(--app-theme-sidebar); +} + +/* The Mac app's thin chrome (Linear's): the traffic lights sit centered in it + (apps/desktop DesktopWindow). Windows and Linux follow their overlay. */ +html.electron:not(.wco) { + --workspace-topbar-height: 42px; +} + +[data-app-frame] { + background-color: var(--app-frame); +} + +/* Where the rail's first space sits: in line with the sidebar's brand row, + the panel's first row (under the title band in the desktop app). */ +[data-space-rail] { + --space-rail-top: calc((var(--workspace-topbar-height) - 2.25rem) / 2); +} + +/* The panes after the rail. The fixed sidebar is positioned inside them. */ +[data-app-frame="window"] [data-app-panel] { + padding: 0 0.25rem 0.25rem 0; +} + +[data-app-frame] [data-slot="sidebar-container"] { + left: var(--workspace-rail-width); +} + +/* Collapsed, the sidebar slides away under the rail. */ +[data-app-frame] [data-collapsible="offcanvas"] [data-slot="sidebar-container"] { + left: calc(var(--workspace-rail-width) - var(--sidebar-width)); +} + +[data-app-frame="window"] [data-slot="sidebar-container"] { + height: auto; + bottom: 0.25rem; +} + +[data-app-frame] [data-slot="sidebar-inner"] { + background: var(--sidebar-panel-surface); +} + +[data-app-frame="window"] [data-slot="sidebar-inner"] { + background: linear-gradient( + to bottom, + transparent var(--workspace-topbar-height), + var(--sidebar-panel-surface) var(--workspace-topbar-height) + ); +} + +/* In the desktop app each pane's title band is part of the frame. */ +[data-app-frame="window"] [data-slot="sidebar-inset"] { + height: auto; + background: linear-gradient( + to bottom, + var(--app-frame) var(--workspace-topbar-height), + var(--background) var(--workspace-topbar-height) + ); +} + +/* The pages' shared header (WorkspacePageHeader): pages such as Settings + paint the canvas behind it, so it carries the frame itself, only as far + down as the band (a header that wraps taller reaches into the panel). */ +[data-app-frame="window"] [data-workspace-page-header] { + background: linear-gradient( + to bottom, + var(--app-frame) var(--workspace-topbar-height), + transparent var(--workspace-topbar-height) + ); +} + +/* Themes paint the header with their toolbar color; on the frame it is the frame's. */ +[data-app-frame="window"] [data-chat-header] { + --toolbar-background: var(--app-frame); + background-color: var(--app-frame); +} + +[data-app-frame="window"] [data-preview-panel-mode="inline"] { + background: linear-gradient( + to bottom, + var(--app-frame) var(--workspace-topbar-height), + var(--background) var(--workspace-topbar-height) + ); +} + +/* The rim and the rounded corners, over the panes (AppFramePanelEdge). */ +[data-app-panel-edge] { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 30; +} + +[data-app-frame="window"] [data-app-panel-edge] { + inset: var(--workspace-topbar-height) 0.25rem 0.25rem 0; +} + +[data-app-panel-edge] > [data-rim] { + position: absolute; + inset: 0; + border: 1px solid var(--panel-edge); + border-radius: var(--app-frame-radius); +} + +[data-app-frame="tab"] [data-app-panel-edge] > [data-rim] { + border-block-width: 0; + border-right-width: 0; + border-radius: var(--app-frame-radius) 0 0 var(--app-frame-radius); +} + +/* Each corner is frame-colored beyond the panel's curve. */ +[data-app-panel-edge] > [data-corner] { + position: absolute; + width: var(--app-frame-radius); + height: var(--app-frame-radius); + --corner-curve: + transparent calc(var(--app-frame-radius) - 0.5px), + var(--app-frame) calc(var(--app-frame-radius) + 0.5px); +} + +[data-app-panel-edge] > [data-corner="top-left"] { + top: 0; + left: 0; + background: radial-gradient(circle at 100% 100%, var(--corner-curve)); +} + +[data-app-panel-edge] > [data-corner="top-right"] { + top: 0; + right: 0; + background: radial-gradient(circle at 0 100%, var(--corner-curve)); +} + +[data-app-panel-edge] > [data-corner="bottom-left"] { + bottom: 0; + left: 0; + background: radial-gradient(circle at 100% 0, var(--corner-curve)); +} + +[data-app-panel-edge] > [data-corner="bottom-right"] { + bottom: 0; + right: 0; + background: radial-gradient(circle at 0 0, var(--corner-curve)); +} + +[data-app-frame="tab"] + [data-app-panel-edge] + > :is([data-corner="top-right"], [data-corner="bottom-right"]) { + display: none; +} + +/* The dev and nightly stage art sits at the top of the panel, behind the + brand, and fades into the sidebar's body. */ +:root [data-app-frame] [data-app-sidebar] { + --sidebar-stage-fade: var(--sidebar-panel-surface); +} + +/* Where the sidebar's heading starts in its first row: past the pinned + sidebar toggle in a tab; in line with the rows below it in the desktop + app's panel, whose title band holds the toggle. */ +:root { + --sidebar-heading-start: 0px; +} + +[data-app-frame="tab"] [data-app-sidebar] { + --sidebar-heading-start: calc( + var(--workspace-titlebar-content-left) - var(--sidebar-row-content-inset) + ); +} + +[data-app-frame="window"] [data-app-sidebar] { + --sidebar-heading-start: var(--sidebar-content-inset); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 918d28845068..a1a4b07d9893 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, + useMemo, useState, useSyncExternalStore, type CSSProperties, @@ -35,16 +36,18 @@ import LegacyThreadSidebar from "./LegacySidebar"; import { useThreadVisitedMigration } from "../hooks/useThreadVisitedMigration"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; -import { SidebarBrandWidthProbe, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarChromeHeader, SidebarSpaceTitle } from "./sidebar/SidebarChrome"; import { MainAppLocationTracker } from "./sidebar/mainAppLocation"; +import { SpaceRail } from "./sidebar/SpaceRail"; +import { useAppFrame } from "./sidebar/appFrame"; import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { clampThreadSidebarWidth, resolveInitialThreadSidebarWidth, resolveThreadSidebarMaximumWidth, - resolveThreadSidebarMinimumWidth, THREAD_MAIN_CONTENT_MIN_WIDTH, + THREAD_SIDEBAR_MIN_WIDTH, THREAD_SIDEBAR_WIDTH_STORAGE_KEY, } from "./threadSidebarWidth"; import { @@ -57,7 +60,12 @@ import { } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 90px)"; +const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "var(--desktop-window-controls-inset, 82px)"; +/** The rail's width (appFrame.css's --workspace-rail-width). */ +const SPACE_RAIL_WIDTH = 52; +/** Where the pinned sidebar toggle sits without traffic lights to clear: past the rail. */ +const FRAMED_CONTROLS_LEFT = + "calc(env(safe-area-inset-left, 0px) + env(titlebar-area-x, 0px) + var(--workspace-rail-width) + 0.75rem)"; function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); @@ -89,6 +97,9 @@ function SidebarControl() { const stageBackdropVariant = useSidebarStageBackdropVariant( environmentIdentificationMode === "artwork", ); + // In the desktop app's frame the toggle sits on the title band, above the art. + const frame = useAppFrame(); + const overArtwork = isSidebarVisible && stageBackdropVariant !== null && frame !== "window"; const shortcutLabel = shortcutLabelForCommand(keybindings, "sidebar.toggle", { context: { usagePageOpen }, }); @@ -141,10 +152,10 @@ function SidebarControl() { @@ -216,6 +227,33 @@ function ProjectProjectionRetention() { return null; } +/** On phones the sidebar is a sheet: the rail runs down its left edge. */ +function MobileSpaceRailLayout({ enabled, children }: { enabled: boolean; children: ReactNode }) { + if (!enabled) return children; + return ( +
+ +
{children}
+
+ ); +} + +/** + * The content panel's rim and rounded corners, drawn over the panes so they + * keep painting their own surfaces (appFrame.css). + */ +function AppFramePanelEdge() { + return ( +
+
+
+
+
+
+
+ ); +} + export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); const legacySidebarEnabled = useLegacySidebarEnabled(); @@ -230,14 +268,20 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const routePanelAnimationsActive = panelAnimationsActive && !panelAnimationsSuppressed; const isOnSettings = pathname === "/settings" || pathname.startsWith("/settings/"); const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); + // Otter Mail's frame: the rail and one inset panel. Phones keep the sheet, + // with the rail inside it. + const frame = useAppFrame(); + const railWidth = frame ? SPACE_RAIL_WIDTH : 0; const [sidebarWidth, setSidebarWidth] = useState(readInitialThreadSidebarWidth); // Subscribed rather than read once: the clamp must track live window size, // and a clamped drag ends with an unchanged width, which skips the re-render // that would otherwise refresh a render-time snapshot. const viewportWidth = useSyncExternalStore(subscribeToViewportWidth, readViewportWidth); - const [brandWidth, setBrandWidth] = useState(0); - const sidebarMinimumWidth = resolveThreadSidebarMinimumWidth(brandWidth); - const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth(viewportWidth, sidebarMinimumWidth); + const sidebarMinimumWidth = THREAD_SIDEBAR_MIN_WIDTH; + const sidebarMaximumWidth = resolveThreadSidebarMaximumWidth( + viewportWidth - railWidth, + sidebarMinimumWidth, + ); const resetSidebarWidth = () => { try { removeLocalStorageItem(THREAD_SIDEBAR_WIDTH_STORAGE_KEY); @@ -255,9 +299,18 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const sidebarProviderStyle = { "--sidebar-width": `${clampThreadSidebarWidth(sidebarWidth, sidebarMinimumWidth, sidebarMaximumWidth)}px`, "--panel-animation-duration": `${panelAnimationDurationMs}ms`, + ...(frame ? { "--workspace-controls-left": FRAMED_CONTROLS_LEFT } : {}), ...(isMacosDesktop && !isWindowFullscreen ? { "--workspace-controls-left": MACOS_TRAFFIC_LIGHTS_LEFT_INSET } : {}), + // Headers place their content after the pinned toggle, in their own + // coordinates: the panes start after the rail. + ...(frame + ? { + "--workspace-titlebar-content-left": + "calc(var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap) - var(--workspace-rail-width))", + } + : {}), } as CSSProperties; useEffect(() => { @@ -277,6 +330,26 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return unsubscribe; }, [isMacosDesktop]); + // The layout re-renders on every window resize (the sidebar's width limits + // follow it); the sidebar's content does not depend on it, so it keeps one + // element and React skips it. + const sidebarContent = useMemo( + () => + isOnSettings ? ( + <> + + Settings + + + + ) : legacySidebarEnabled ? ( + + ) : ( + + ), + [isOnSettings, legacySidebarEnabled, pathname], + ); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { @@ -301,41 +374,40 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { - - - nextWidth <= currentWidth || - wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, - storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, - onResize: setSidebarWidth, - }} + {/* Above the panel, so the sidebar slides away under it. */} + {frame ? : null} +
- {isOnSettings ? ( - <> - - - - ) : legacySidebarEnabled ? ( - - ) : ( - - )} - - - {children} + + nextWidth <= currentWidth || + wrapper.clientWidth - railWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + onResize: setSidebarWidth, + }} + > + {sidebarContent} + + + {children} + {frame ? : null} +
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 484ff5e58769..4ba637fd96e5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10827,9 +10827,6 @@ export default function ChatView(props: ChatViewProps) { onDeleteProjectScript: deleteProjectScript, }; const panelToggleControlProps = { - terminalAvailable: activeProject !== null, - terminalOpen: terminalUiState.terminalOpen, - terminalShortcutLabel: shortcutLabelForCommand(keybindings, "terminal.toggle"), threadPanelOpen, threadPanelPresentation, threadPanelPopoverHandle, @@ -10839,7 +10836,6 @@ export default function ChatView(props: ChatViewProps) { rightPanelAvailable: activeProject !== null, rightPanelOpen, rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), - onToggleTerminal: toggleTerminalVisibility, onToggleThreadPanel: toggleThreadPanel, onToggleRightPanel: toggleRightPanel, } satisfies PanelLayoutControlsProps; @@ -10854,11 +10850,7 @@ export default function ChatView(props: ChatViewProps) { className="absolute top-[var(--workspace-controls-top)] right-[var(--workspace-controls-right)] z-50 flex h-[var(--workspace-topbar-height)] items-center [-webkit-app-region:no-drag]" data-workspace-titlebar-controls > - +
); const panelLayoutControls = ( @@ -10877,9 +10869,9 @@ export default function ChatView(props: ChatViewProps) { "flex shrink-0", panelAnimationsActive && "motion-safe:transition-opacity motion-safe:duration-(--panel-animation-duration) motion-safe:ease-out", - // Closed, the control leaves the flex flow so the cluster is only as wide as the two + // Closed, the control leaves the flex flow so the cluster is only as wide as the // toggles the header reserves room for; anchored to the cluster's left edge, it fades - // out where it stood rather than over the terminal toggle. + // out where it stood rather than over the toggles. rightPanelOpen ? "pointer-events-auto opacity-100" : "pointer-events-none absolute right-full mr-1 opacity-0", diff --git a/apps/web/src/components/PaneIcons.tsx b/apps/web/src/components/PaneIcons.tsx new file mode 100644 index 000000000000..797246b160ba --- /dev/null +++ b/apps/web/src/components/PaneIcons.tsx @@ -0,0 +1,64 @@ +/** + * The title band's pane toggles (Otter Mail's, after ChatGPT's and Linear's): + * a soft frame in Lucide's strokes, so they sit with the rest of the icons. + */ + +/** A side pane's toggle: the pane is a filled bar while open, a thin line while closed. */ +export function PaneIcon({ + side, + open, + className, +}: { + side: "left" | "right"; + open: boolean; + className?: string; +}) { + const x = side === "left" ? 8 : 16; + return ( + + + {open ? ( + + ) : ( + + )} + + ); +} + +/** The thread details toggle: two items, each a dot and its line. */ +export function DetailsIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 058619e59fb4..84291f13e392 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -265,8 +265,13 @@ import { useComboboxFilter, } from "./ui/combobox"; import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; -import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; +import { + SidebarChromeFooter, + SidebarChromeHeader, + SidebarSpaceHeading, + SidebarSpaceTitle, +} from "./sidebar/SidebarChrome"; +import { SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuShortcut, MenuTrigger } from "./ui/menu"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; @@ -2673,8 +2678,8 @@ export default function Sidebar() { }, [isMobile, router, setOpenMobile], ); - // Anchor for the scope popup: the header search field, not its icon trigger. - const headerSearchRef = useRef(null); + // Anchor for the scope popup: the heading's row, not its text. + const headingRowRef = useRef(null); // Safari can send a click after Ctrl+click opens settings. Ignore that one // selection, then clear the guard when the picker opens again. const suppressNextScopeChangeRef = useRef(false); @@ -4846,10 +4851,137 @@ export default function Sidebar() { shortcutLabelForCommand(keybindings, "chat.new") ?? (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); + // The space the rail chose, named in the sidebar's heading. + const spaceTitle = scopedProjectGroup?.displayName ?? "All projects"; return ( <> - + + {projectGroups.length > 0 ? ( + item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={projectScopeMenuState.open} + onOpenChange={(open) => { + if (open) suppressNextScopeChangeRef.current = false; + dispatchProjectScopeMenu({ type: "open-changed", open }); + }} + onItemHighlighted={(item) => { + highlightedProjectScopeKeyRef.current = item?.value ?? null; + }} + value={selectedProjectScopeItem} + onValueChange={(item) => { + if (suppressNextScopeChangeRef.current) { + suppressNextScopeChangeRef.current = false; + return; + } + if (!item) return; + setProjectScopeKey(item.value === "all" ? null : item.value); + }} + > + + } + /> + + { + if ( + event.defaultPrevented || + event.nativeEvent.isComposing || + event.ctrlKey || + event.altKey || + event.metaKey || + (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + ) { + return; + } + // Combobox items use virtual focus: keyboard events + // stay on this input, not on the highlighted option. + const scopeKey = highlightedProjectScopeKeyRef.current; + const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + if (project) handleProjectSettings(event, project); + }} + onChange={(event) => + dispatchProjectScopeMenu({ + type: "query-changed", + query: event.target.value, + }) + } + /> + No matching projects. + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; + return ( + { + if (project) handleProjectSettings(event, project); + }} + > + {project ? ( + + ) : ( + + )} + {item.label} + {project && showProjectEnvironments ? ( + + ) : null} + {project ? ( + + ) : null} + + ); + }} + + + + ) : ( + {spaceTitle} + )} + 0} - projectScope={ - item.label} - isItemEqualToValue={(a, b) => a.value === b.value} - open={projectScopeMenuState.open} - onOpenChange={(open) => { - if (open) suppressNextScopeChangeRef.current = false; - dispatchProjectScopeMenu({ type: "open-changed", open }); - }} - onItemHighlighted={(item) => { - highlightedProjectScopeKeyRef.current = item?.value ?? null; - }} - value={selectedProjectScopeItem} - onValueChange={(item) => { - if (suppressNextScopeChangeRef.current) { - suppressNextScopeChangeRef.current = false; - return; - } - if (!item) return; - setProjectScopeKey(item.value === "all" ? null : item.value); - }} - > - - } - > - {scopedProjectGroup ? ( - // Wrapped so the button's direct-child svg color rule cannot override - // a project's own icon color. - - - - ) : ( - - )} - - - { - if ( - event.defaultPrevented || - event.nativeEvent.isComposing || - event.ctrlKey || - event.altKey || - event.metaKey || - (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) - ) { - return; - } - // Combobox items use virtual focus: keyboard events - // stay on this input, not on the highlighted option. - const scopeKey = highlightedProjectScopeKeyRef.current; - const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; - if (project) handleProjectSettings(event, project); - }} - onChange={(event) => - dispatchProjectScopeMenu({ - type: "query-changed", - query: event.target.value, - }) - } - /> - No matching projects. - - {(item: (typeof projectScopeItems)[number]) => { - const project = projectGroupByScopeKey.get(item.value) ?? null; - return ( - { - if (project) handleProjectSettings(event, project); - }} - > - {project ? ( - - ) : ( - - )} - {item.label} - {project && showProjectEnvironments ? ( - - ) : null} - {project ? ( - - ) : null} - - ); - }} - - - - } - onNewProject={openAddProjectCommandPalette} onNewThread={handleNewThreadClick} newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} diff --git a/apps/web/src/components/WorkspacePageHeader.tsx b/apps/web/src/components/WorkspacePageHeader.tsx index a484b4497539..825982f16dda 100644 --- a/apps/web/src/components/WorkspacePageHeader.tsx +++ b/apps/web/src/components/WorkspacePageHeader.tsx @@ -15,6 +15,7 @@ export function WorkspacePageHeader({ }) { return (
({ registerTimeline }), [registerTimeline]); const { layout } = context; return ( - -
+ +
- {children} -
-
+ {...props} + ref={elementRef} + data-chat-canvas + data-preview-overlaps-chat={layout.overlapsChat || undefined} + className="relative flex min-h-0 min-w-0 flex-1 flex-col" + style={ + { + "--chat-timeline-gutter": `${measurements.timelineGutter}px`, + "--chat-lane-inset-start": `${layout.chat.insetStart}px`, + "--chat-lane-inset-end": `${layout.chat.insetEnd}px`, + } as CSSProperties + } + > +
+ {children} +
+ +
); } diff --git a/apps/web/src/components/chat/ChatCanvasContext.ts b/apps/web/src/components/chat/ChatCanvasContext.ts index 44b91f82632a..e89eca562f33 100644 --- a/apps/web/src/components/chat/ChatCanvasContext.ts +++ b/apps/web/src/components/chat/ChatCanvasContext.ts @@ -14,3 +14,14 @@ export const ChatCanvasContext = createContext<{ } | null>(null); export const useChatCanvas = () => useContext(ChatCanvasContext); + +/** + * The canvas's callbacks alone. They never change, so a consumer that only + * registers or reports does not re-render each frame the canvas resizes (a + * sidebar or panel animating open beside it). + */ +export const ChatCanvasActionsContext = createContext<{ + registerTimeline: (element: HTMLElement | null) => void; +} | null>(null); + +export const useChatCanvasActions = () => useContext(ChatCanvasActionsContext); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6320d7517bc0..a015fc93594d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,5 +1,5 @@ import { ComputerUseAppIcon } from "~/components/Icons"; -import { useChatCanvas } from "./ChatCanvasContext"; +import { useChatCanvasActions } from "./ChatCanvasContext"; import { WorkLogBlock, WorkLogButton, WorkLogDetails, WorkLogList, WorkLogRow } from "./WorkLog"; import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; import type { WorktreeSetupSnapshot } from "@t3tools/contracts"; @@ -1281,8 +1281,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }, [historyControls, onOpenThread, parentThreadLink, topFadeEnabled]); - const canvas = useChatCanvas(); - const registerTimeline = canvas?.registerTimeline; + const registerTimeline = useChatCanvasActions()?.registerTimeline; const setTimelineList = useCallback( (list: LegendListRef | null) => { listRef.current = list; diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index ff6f00e28272..3d63d6d7b89e 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,8 +1,9 @@ -import { PanelBottomIcon, PanelRightIcon, SquareMenuIcon } from "lucide-react"; import { Maximize2, Minimize2 } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; import { memo, type ReactElement } from "react"; +import { DetailsIcon, PaneIcon } from "../PaneIcons"; + import type { ThreadPanelPresentation } from "../../rightPanelLayout"; import { PopoverCreateHandle, PopoverTrigger } from "../ui/popover"; import { Toggle } from "../ui/toggle"; @@ -10,11 +11,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface PanelLayoutControlsProps { showThreadPanelControl?: boolean; - showTerminalControl?: boolean; showRightPanelControl?: boolean; - terminalAvailable: boolean; - terminalOpen: boolean; - terminalShortcutLabel: string | null; threadPanelOpen: boolean; threadPanelPresentation: ThreadPanelPresentation; threadPanelPopoverHandle?: ReturnType; @@ -24,18 +21,13 @@ export interface PanelLayoutControlsProps { rightPanelOpen: boolean; rightPanelShortcutLabel: string | null; rightPanelUnavailableLabel?: string; - onToggleTerminal: () => void; onToggleThreadPanel: () => void; onToggleRightPanel: () => void; } export const PanelLayoutControls = memo(function PanelLayoutControls({ showThreadPanelControl = true, - showTerminalControl = true, showRightPanelControl = true, - terminalAvailable, - terminalOpen, - terminalShortcutLabel, threadPanelOpen, threadPanelPresentation, threadPanelPopoverHandle, @@ -45,7 +37,6 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ rightPanelOpen, rightPanelShortcutLabel, rightPanelUnavailableLabel = "Right panel is unavailable", - onToggleTerminal, onToggleThreadPanel, onToggleRightPanel, }: PanelLayoutControlsProps) { @@ -57,7 +48,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ variant="ghost" size="sm" > - + {threadPanelHasAttention ? ( - }> - - - - - - {terminalAvailable - ? `Toggle terminal drawer${terminalShortcutLabel ? ` (${terminalShortcutLabel})` : ""}` - : "Terminal drawer is unavailable"} - - - ) : null} {showRightPanelControl ? ( }> @@ -125,7 +94,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ size="sm" disabled={!rightPanelAvailable} > - + diff --git a/apps/web/src/components/chat/ThreadDetailsCard.tsx b/apps/web/src/components/chat/ThreadDetailsCard.tsx index dfee1453eda9..a92a17a0248c 100644 --- a/apps/web/src/components/chat/ThreadDetailsCard.tsx +++ b/apps/web/src/components/chat/ThreadDetailsCard.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; +import { useLayoutEffect, useMemo, useState, type ReactNode, type RefObject } from "react"; import type { ScopedThreadRef } from "@t3tools/contracts"; import { ScrollArea } from "../ui/scroll-area"; import { cn } from "../../lib/utils"; @@ -100,6 +100,9 @@ export function ThreadDetailsCard({ observer.observe(element); return () => observer.disconnect(); }, [contentElement, density, measurementKey]); + // Placement follows the canvas every frame it resizes (a sidebar or panel + // animating beside it); the content only when its density or source does. + const content = useMemo(() => children(density), [children, density]); const card = (
-
{children(density)}
+
{content}
); @@ -129,7 +132,10 @@ export function ThreadDetailsCard({ aria-label="Thread details" className="absolute z-20" style={{ - left: placement.x, + // Pinned to the right edge rather than at the measured left: the + // canvas measures a frame behind a sidebar animating beside it, + // and the card would trail its edge, then snap back. + right: (canvas?.container.width ?? 0) - placement.x - placement.width, top: placement.y, width: placement.width, maxHeight: height, diff --git a/apps/web/src/components/settings/ThemeSettings.tsx b/apps/web/src/components/settings/ThemeSettings.tsx index b0e474ad475b..533adab27de2 100644 --- a/apps/web/src/components/settings/ThemeSettings.tsx +++ b/apps/web/src/components/settings/ThemeSettings.tsx @@ -22,6 +22,7 @@ import { type ThemeAppearance, type ThemeDefinition, type ThemeHalves, + CHATGPT_THEME, T3_CHAT_THEME, EMBER_THEME, GROVE_THEME, @@ -55,6 +56,7 @@ import { import { ThemeWireframe } from "./ThemeWireframe"; const MAINTAINER_THEMES: ReadonlyArray = [ + CHATGPT_THEME, T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 5346a6753c77..2faff6dd8b29 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,11 +1,9 @@ -import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; -import type { ReactNode } from "react"; -import { memo, useCallback } from "react"; -import { Link, useLocation, useNavigate } from "@tanstack/react-router"; +import { ArrowLeftIcon, ChevronDownIcon } from "lucide-react"; +import { memo, useCallback, type ComponentProps, type ReactNode, type RefObject } from "react"; +import { useLocation } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; -import { usePullRequestsSupported } from "../../state/environments"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, @@ -21,18 +19,29 @@ import { SidebarTrigger, useSidebar, } from "../ui/sidebar"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences"; +import { useAppFrame } from "./appFrame"; import { isSidebarUtilityPage, useNavigateToMainApp } from "./mainAppLocation"; import { SidebarThreadUndoNotice } from "./SidebarThreadUndoNotice"; import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill"; import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill"; -import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +/** + * The sidebar's first row, which names the space it shows (`children`: its + * heading). There's no wordmark, as in Otter Mail. The dev and nightly stage + * art sits behind the row. In the desktop app's frame the title band above + * the panel stays empty for the window controls and the pinned toggle, so + * the row is the panel's first; in a browser tab it shares the band, after + * the toggle. + */ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ isElectron, + headingRef, + children, }: { isElectron: boolean; + /** The heading's row, for a popup to anchor to its width. */ + headingRef?: RefObject; + children?: ReactNode; }) { const stageLabel = useEnvironmentStageLabel(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); @@ -44,161 +53,97 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ environmentIdentificationMode === "pill" ? resolveEnvironmentIdentificationPillLabel(stageLabel) : null; + const inPanel = useAppFrame() === "window"; return ( - // The titlebar row, not a padded SidebarHeader: it aligns to the window controls. -
- {backdropVariant ? : null} - - {/* One visible line: the pill wraps onto the clipped second line once it no longer fits. - The padding keeps the brand's focus ring inside the clip. */} -
- - {pillLabel ? ( -
- - {pillLabel} - -
- ) : null} + <> + {inPanel ? ( +
+ ) : null} + {/* The titlebar row, not a padded SidebarHeader: it aligns to the window controls. */} +
+ {backdropVariant ? : null} + + {/* One visible line: the pill wraps onto the clipped second line once it no longer fits. + The padding keeps the heading's focus ring inside the clip. */} +
+ {children} + {pillLabel ? ( +
+ + {pillLabel} + +
+ ) : null} +
-
+ ); }); -// Measures the brand at its titlebar inset, plus the header's right padding and the -// sidebar border, so the sidebar minimum follows font size, zoom and macOS window controls. -export function SidebarBrandWidthProbe({ - onWidthChange, -}: { - onWidthChange: (width: number) => void; -}) { - const observeWidth = useCallback( - (probe: HTMLDivElement) => { - const observer = new ResizeObserver(([entry]) => { - if (entry) onWidthChange(entry.borderBoxSize[0]?.inlineSize ?? probe.offsetWidth); - }); - observer.observe(probe); - return () => observer.disconnect(); - }, - [onWidthChange], - ); - +/** A sidebar's heading (Otter Mail's): the space it shows, large. */ +export function SidebarSpaceTitle({ children }: { children: ReactNode }) { return ( - +

+ {children} +

); } -function SidebarBrand({ onBackdrop }: { onBackdrop: boolean }) { +/** + * The space's heading as the project picker's trigger: its name, with a + * chevron that shows on hover or while the picker is open. Spreads unknown + * props through so a popup trigger can inject its handlers, ref and aria state. + */ +export function SidebarSpaceHeading({ + title, + className, + ...rest +}: { title: string } & Omit, "children">) { return ( - - - - ); -} - -function SidebarBrandMark({ onBackdrop }: { onBackdrop: boolean }) { - return ( - // Center the visible capitals, without the font's ascender/descender space. - - Otter - - Code - - - ); -} - -function SidebarUtilityItem({ - icon, - label, - onClick, -}: { - icon: ReactNode; - label: string; - onClick: () => void; -}) { - return ( - - - - {icon} - - } - /> - {label} - - + {title} + + ); } +// Settings, Usage and Pull Requests live in the space rail; their pages +// offer Back here, to the thread they were opened over. export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { - const navigate = useNavigate(); const navigateToMainApp = useNavigateToMainApp(); const { isMobile, setOpenMobile } = useSidebar(); const isOnUtilityPage = useLocation({ select: (location) => isSidebarUtilityPage(location.pathname), }); - const pullRequestsSupported = usePullRequestsSupported(); - const closeMobileSidebar = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - }, [isMobile, setOpenMobile]); - const handlePullRequestsClick = useCallback(() => { - closeMobileSidebar(); - void navigate({ - to: "/pull-requests", - search: readPullRequestListPreferences(), - }); - }, [closeMobileSidebar, navigate]); - const handleSettingsClick = useCallback(() => { - closeMobileSidebar(); - void navigate({ to: "/settings" }); - }, [closeMobileSidebar, navigate]); - - const handleUsageClick = useCallback(() => { - if (isMobile) { - setOpenMobile(false); - } - void navigate({ to: "/usage" }); - }, [isMobile, navigate, setOpenMobile]); const handleBackClick = useCallback(() => { - closeMobileSidebar(); + if (isMobile) setOpenMobile(false); void navigateToMainApp(); - }, [closeMobileSidebar, navigateToMainApp]); + }, [isMobile, navigateToMainApp, setOpenMobile]); return ( @@ -209,27 +154,7 @@ export const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { Back - ) : ( - <> - } - label="Settings" - onClick={handleSettingsClick} - /> - {pullRequestsSupported ? ( - } - label="Pull Requests" - onClick={handlePullRequestsClick} - /> - ) : null} - } - label="Usage" - onClick={handleUsageClick} - /> - - )} + ) : null} ); diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index e2bd4bfb74e5..1e7e5fa6e7c6 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -1,16 +1,9 @@ /** - * The sidebar header: one row holding search, project scope and new thread. - * - * Search owns the row's text and spans it. Project scope collapses to an icon - * that sits with new-project and new-thread as a segmented group at the end. - * The scope icon swaps to the project favicon while a project is selected, - * so the header still names the scope after the row that showed it is gone. - * - * The scope picker itself is passed in: its combobox state lives with the rest - * of the sidebar's scope logic. `searchFieldRef` lands on the search field so - * the picker's popup can anchor to that width rather than to its 28px trigger. + * The sidebar header: one row holding search and new thread. The space it + * searches (All projects, or one) is named in the row above it + * (`SidebarChromeHeader`), which the space rail and its picker choose. */ -import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react"; +import { SearchIcon, SquarePenIcon, XIcon } from "lucide-react"; import { type ComponentProps, type KeyboardEvent as ReactKeyboardEvent, @@ -25,13 +18,6 @@ import { SidebarInput, SidebarMenuButton } from "../ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface SidebarThreadHeaderProps { - /** Lands on the search field so a popup can anchor to its width. */ - searchFieldRef?: RefObject; - /** Without projects there is nothing to scope, so those controls stay out. */ - hasProjects: boolean; - /** The project scope combobox, rendered as the first icon of the group. */ - projectScope: ReactNode; - onNewProject: () => void; /** Receives the click so Shift+click can skip the project picker. */ onNewThread: (event: ReactMouseEvent) => void; newThreadDisabled: boolean; @@ -50,10 +36,6 @@ export interface SidebarThreadHeaderProps { } export function SidebarThreadHeader({ - searchFieldRef, - hasProjects, - projectScope, - onNewProject, onNewThread, newThreadDisabled, newThreadShortcutLabel, @@ -79,10 +61,7 @@ export function SidebarThreadHeader({ return (
-
+
) : null}
- {/* Unfilled like the search field beside it: the buttons carry their own - hover states, and a background well reads far louder on themed + {/* Unfilled like the search field beside it: the button carries its own + hover state, and a background well reads far louder on themed palettes than on the base light and dark ones. */}
- {hasProjects ? ( - <> - {projectScope} - - - - - ) : null} void; + children: ReactNode; +}) { + return ( + + + } + > + {children} + + {label} + + ); +} + +function RailDivider() { + return ; +} + +/** + * The project spaces, in the user's manual project order: a rail keeps its + * places, so it never re-sorts by activity the way the thread list can. + */ +function useRailProjectGroups() { + const projects = useProjects(); + const projectOrder = useUiStateStore((store) => store.projectOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const environments = useEnvironmentIdentities(); + return useMemo(() => { + const labelById = new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ); + return buildSidebarProjectSnapshots({ + projects: orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => labelById.get(environmentId) ?? null, + }); + }, [environments, primaryEnvironmentId, projectGroupingSettings, projectOrder, projects]); +} + +export const SpaceRail = memo(function SpaceRail({ className }: { className?: string }) { + const navigate = useNavigate(); + const navigateToMainApp = useNavigateToMainApp(); + const { isMobile, open, setOpen, setOpenMobile } = useSidebar(); + const place = useLocation({ select: (location) => railPlace(location.pathname) }); + const pullRequestsSupported = usePullRequestsSupported(); + // The legacy sidebar groups threads by project itself and ignores the scope. + const legacySidebarEnabled = useLegacySidebarEnabled(); + const projectGroups = useRailProjectGroups(); + const scopeKey = useUiStateStore((store) => store.sidebarProjectScopeKey); + const setScopeKey = useUiStateStore((store) => store.setSidebarProjectScopeKey); + + const leaveSheet = () => { + if (isMobile) setOpenMobile(false); + }; + // A space's threads show in the sidebar, so choosing one brings it back, + // and leaves a utility page for the thread it was opened over. + const selectSpace = (projectKey: string | null) => { + setScopeKey(projectKey); + if (!isMobile && !open) void setOpen(true); + if (place !== "threads") void navigateToMainApp(); + }; + const threadsShowing = place === "threads"; + + return ( + + ); +}); diff --git a/apps/web/src/components/sidebar/appFrame.ts b/apps/web/src/components/sidebar/appFrame.ts new file mode 100644 index 000000000000..a979468a1a5b --- /dev/null +++ b/apps/web/src/components/sidebar/appFrame.ts @@ -0,0 +1,14 @@ +import { isElectron } from "../../env"; +import { useIsMobile } from "../../hooks/useMediaQuery"; + +/** + * Otter Mail's window frame (appFrame.css): in the desktop app ("window") the + * panes' headers sit on a title band above the content panel; in a browser + * tab ("tab") the panel runs to the page's edges. Phones have none: the + * sidebar is a sheet there. + */ +export function useAppFrame(): "window" | "tab" | null { + const isMobile = useIsMobile(); + if (isMobile) return null; + return isElectron ? "window" : "tab"; +} diff --git a/apps/web/src/components/threadSidebarWidth.test.ts b/apps/web/src/components/threadSidebarWidth.test.ts index 41f6b97c9283..68bf921da283 100644 --- a/apps/web/src/components/threadSidebarWidth.test.ts +++ b/apps/web/src/components/threadSidebarWidth.test.ts @@ -1,21 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { - clampThreadSidebarWidth, - resolveThreadSidebarMaximumWidth, - resolveThreadSidebarMinimumWidth, - THREAD_SIDEBAR_MIN_WIDTH, -} from "./threadSidebarWidth"; - -describe("resolveThreadSidebarMinimumWidth", () => { - it("keeps the default minimum when the brand fits", () => { - expect(resolveThreadSidebarMinimumWidth(0)).toBe(THREAD_SIDEBAR_MIN_WIDTH); - expect(resolveThreadSidebarMinimumWidth(194)).toBe(THREAD_SIDEBAR_MIN_WIDTH); - }); - - it("grows to a brand wider than the default, rounding up", () => { - expect(resolveThreadSidebarMinimumWidth(237.2)).toBe(238); - }); -}); +import { clampThreadSidebarWidth, resolveThreadSidebarMaximumWidth } from "./threadSidebarWidth"; describe("resolveThreadSidebarMaximumWidth", () => { it("never drops below a raised minimum on a narrow viewport", () => { diff --git a/apps/web/src/components/threadSidebarWidth.ts b/apps/web/src/components/threadSidebarWidth.ts index ea9967c1f06d..6cd0e9a12fa2 100644 --- a/apps/web/src/components/threadSidebarWidth.ts +++ b/apps/web/src/components/threadSidebarWidth.ts @@ -3,11 +3,6 @@ const THREAD_SIDEBAR_DEFAULT_WIDTH = 16 * 16; export const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; export const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; -// The brand's measured width can raise the minimum so "T3 Code" never clips. -export function resolveThreadSidebarMinimumWidth(brandWidth: number): number { - return Math.max(THREAD_SIDEBAR_MIN_WIDTH, Math.ceil(brandWidth)); -} - export function resolveThreadSidebarMaximumWidth( viewportWidth: number, minimumWidth = THREAD_SIDEBAR_MIN_WIDTH, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 19fc8666efca..54549f9c65e8 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -1,12 +1,11 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; -import { PanelLeft, PanelLeftClose } from "lucide"; import * as React from "react"; import { cn } from "~/lib/utils"; import { Button } from "~/components/ui/button"; import { Input, type InputProps } from "~/components/ui/input"; -import { MorphIcon } from "~/components/MorphIcon"; +import { PaneIcon } from "~/components/PaneIcons"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Sheet, @@ -343,7 +342,7 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps - + Toggle Sidebar ); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 46b57325a3ff..c44a5eefcd61 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "./appFrame.css"; @custom-variant dark (&:is(.dark, .dark *)); @custom-variant light (&:not(.dark, .dark *)); @@ -695,8 +696,8 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil display: none; } - /* Each maintainer palette gives the same line art its own material: rose - vellum, forest drafting paper, marine cyanotype, copper, and violet ink. + /* Each maintainer palette gives the same line art its own material: graphite, + rose vellum, forest drafting paper, marine cyanotype, copper, and violet ink. These colors stay deliberately deep at the top edge so the white stage wordmark and controls retain contrast in both appearance modes. */ html[data-theme-id="t3-chat"] { @@ -827,6 +828,41 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } } + /* ChatGPT's graphite: its neutral grays, with no hue. */ + html[data-theme-id="chatgpt"] { + --stage-art-top: oklch(0.52 0 0); + --stage-art-mid: oklch(0.4 0 0); + --stage-art-bottom: oklch(0.29 0 0); + --stage-art-highlight: oklch(0.96 0 0); + --stage-art-secondary: oklch(0.74 0 0); + --stage-art-tertiary: oklch(0.64 0 0); + --stage-art-line: oklch(0.97 0 0); + --stage-night-top: oklch(0.36 0 0); + --stage-night-mid: oklch(0.28 0 0); + --stage-night-bottom: oklch(0.2 0 0); + --stage-night-highlight: oklch(0.92 0 0); + --stage-night-secondary: oklch(0.6 0 0); + --stage-night-tertiary: oklch(0.5 0 0); + --stage-night-line: oklch(0.94 0 0); + + @variant dark { + --stage-art-top: oklch(0.42 0 0); + --stage-art-mid: oklch(0.33 0 0); + --stage-art-bottom: oklch(0.25 0 0); + --stage-art-highlight: oklch(0.93 0 0); + --stage-art-secondary: oklch(0.64 0 0); + --stage-art-tertiary: oklch(0.55 0 0); + --stage-art-line: oklch(0.94 0 0); + --stage-night-top: oklch(0.33 0 0); + --stage-night-mid: oklch(0.26 0 0); + --stage-night-bottom: oklch(0.2 0 0); + --stage-night-highlight: oklch(0.88 0 0); + --stage-night-secondary: oklch(0.55 0 0); + --stage-night-tertiary: oklch(0.46 0 0); + --stage-night-line: oklch(0.9 0 0); + } + } + :is(html[data-theme-id="t3-chat"], html[data-theme-id="ocean"], html[data-theme-id="iris"]) { --stage-night-top: color-mix(in oklch, var(--stage-art-top) 38%, var(--stage-night-base-top)); --stage-night-mid: color-mix(in oklch, var(--stage-art-mid) 38%, var(--stage-night-base-mid)); @@ -850,6 +886,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } :is( + html[data-theme-id="chatgpt"], html[data-theme-id="t3-chat"], html[data-theme-id="grove"], html[data-theme-id="ocean"], diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 69329c7fdc14..5c79e51df303 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1835,11 +1835,7 @@ function PullRequestsRouteView() { ); const panelToggleControls = ( undefined} onToggleRightPanel={toggleRightPanel} /> ); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index d4a1c4e8cc00..72b1613bfa8c 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -4,6 +4,7 @@ import "culori/css"; import { converter, parse } from "culori/fn"; import { BUILT_IN_THEMES, + CHATGPT_THEME, EMBER_THEME, GROVE_THEME, IRIS_THEME, @@ -20,7 +21,15 @@ import { type ThemeVariants, } from "@t3tools/shared/themePalettes"; -export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME_COLOR_ROLES }; +export { + CHATGPT_THEME, + EMBER_THEME, + GROVE_THEME, + IRIS_THEME, + OCEAN_THEME, + T3_CHAT_THEME, + THEME_COLOR_ROLES, +}; export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; export const T3_CHAT_THEME_ID = "t3-chat" as const; diff --git a/assets/otter/otter-macos-1024.png b/assets/otter/otter-macos-1024.png index 33109809b7c8..c73ec555f343 100644 Binary files a/assets/otter/otter-macos-1024.png and b/assets/otter/otter-macos-1024.png differ diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index aed09c042794..d58036edcb54 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -43,6 +43,16 @@ in a new thread's model picker to add or remove them. A regular click returns to single model. Choose a base branch and send. Each selection starts a separate thread and worktree while you stay in the new thread composer. This requires a Git project. +## Switch projects + +On web and desktop, the rail down the window's left edge holds **All projects**, then +each of your projects, **+** to add one, and **Pull requests**. Pick a project to list +only its threads in the sidebar. The sidebar's heading names the project; click it to +search every project by name. **Usage** and **Settings** sit at the foot of the rail. +In a narrow browser window, the rail runs down the edge of the sidebar. With +**Sidebar (legacy)** on, which groups threads by project itself, the rail holds no +projects. + ## Pin and reorder threads Pin a thread from its menu to keep it above your active work. diff --git a/docs/user/usage.md b/docs/user/usage.md index 1ac918d5c52c..8f5aa8aba216 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,8 +1,8 @@ # Usage and limits -Open **Usage** from the sidebar or the command palette, or press `mod+u` on web and -desktop when the terminal is not focused. Customize `usage.open` in -**Settings → Keybindings**. +Open **Usage** from the foot of the rail at the window's left edge or from the command +palette, or press `mod+u` on web and desktop when the terminal is not focused. Customize +`usage.open` in **Settings → Keybindings**. ## Understand your usage diff --git a/packages/shared/src/themePalettes.ts b/packages/shared/src/themePalettes.ts index b26244c04641..06d8bad7228f 100644 --- a/packages/shared/src/themePalettes.ts +++ b/packages/shared/src/themePalettes.ts @@ -1,4 +1,11 @@ -export const BUILT_IN_THEME_IDS = ["t3-chat", "grove", "ocean", "ember", "iris"] as const; +export const BUILT_IN_THEME_IDS = [ + "chatgpt", + "t3-chat", + "grove", + "ocean", + "ember", + "iris", +] as const; /** The standard T3 Code palette, kept separate from the optional built-in theme library. */ export const MOBILE_DEFAULT_THEME_ID = "t3-code"; @@ -248,6 +255,135 @@ export const T3_CODE_DARK_THEME_COLORS: ThemeColors = { terminalScrollbarHover: "#363636", }; +/** ChatGPT's neutral grays, with its blue for focus and accents. */ +export const CHATGPT_THEME: ThemeDefinition = { + id: "chatgpt", + label: "ChatGPT", + appearance: "dark", + colors: { + canvas: "oklch(0.209036 0 0)", + chrome: "oklch(0.243535 0 0)", + toolbar: "oklch(0.209036 0 0)", + toolbarForeground: "oklch(0.943089 0 0)", + toolbarBorder: "oklch(0.285017 0 0)", + toolbarControl: "oklch(0.285017 0 0)", + toolbarControlForeground: "oklch(0.943089 0 0)", + toolbarControlHover: "oklch(0.301184 0 0)", + surface: "oklch(0.22645 0 0)", + surfaceRaised: "oklch(0.309186 0 0)", + surfaceOverlay: "oklch(0.297163 0 0)", + text: "oklch(0.943089 0 0)", + textMuted: "oklch(0.715474 0 0)", + border: "oklch(0.293128 0 0)", + input: "oklch(0.34846 0 0)", + focus: "oklch(0.682034 0.173444 251.111)", + accent: "oklch(0.682034 0.173444 251.111)", + accentForeground: "oklch(1 0 0)", + secondary: "oklch(0.256153 0 0)", + secondaryForeground: "oklch(0.943089 0 0)", + muted: "oklch(0.239292 0 0)", + mutedForeground: "oklch(0.715474 0 0)", + placeholder: "oklch(0.633429 0 0)", + secondaryLabel: "oklch(0.715474 0 0)", + iconMuted: "oklch(0.686223 0 0)", + error: "oklch(0.711607 0.181221 22.839)", + errorForeground: "oklch(0.761477 0.142118 20.941)", + errorSurface: "oklch(0.275482 0.042201 20.133)", + warning: "oklch(0.781881 0.158453 72.327)", + warningForeground: "oklch(0.851709 0.129461 75.72)", + warningSurface: "oklch(0.290302 0.030891 72.342)", + update: "oklch(0.682034 0.173444 251.111)", + updateForeground: "oklch(0.753343 0.132788 248.561)", + updateSurface: "oklch(0.283183 0.040348 250.752)", + accentSurface: "oklch(0.268618 0 0)", + accentSurfaceForeground: "oklch(0.943089 0 0)", + messageSurface: "oklch(0.305191 0 0)", + messageForeground: "oklch(0.943089 0 0)", + messageAction: "oklch(1 0 0)", + messageActionForeground: "oklch(0.159066 0 0)", + messageActionHover: "oklch(0.921906 0 0)", + codeBackground: "oklch(0.256153 0 0)", + codeForeground: "oklch(0.943089 0 0)", + sidebar: "oklch(0.243535 0 0)", + sidebarForeground: "oklch(0.943089 0 0)", + sidebarMutedForeground: "oklch(0.686223 0 0)", + sidebarControlSurface: "oklch(0.301184 0 0)", + sidebarRowHover: "oklch(0.28094 0 0)", + sidebarRowActive: "oklch(0.321093 0 0)", + sidebarRowSelected: "oklch(0.301184 0 0)", + sidebarBorder: "oklch(0.285017 0 0)", + terminalBackground: "oklch(0.209036 0 0)", + terminalForeground: "oklch(0.943089 0 0)", + terminalCursor: "oklch(0.943089 0 0)", + terminalSelection: "oklch(0.341701 0.061076 251.57)", + terminalScrollbar: "oklch(0.305191 0 0)", + terminalScrollbarHover: "oklch(0.360023 0 0)", + }, + variants: { + light: { + canvas: "oklch(1 0 0)", + chrome: "oklch(0.982118 0 0)", + toolbar: "oklch(1 0 0)", + toolbarForeground: "oklch(0.159066 0 0)", + toolbarBorder: "oklch(0.930999 0 0)", + toolbarControl: "oklch(1 0 0)", + toolbarControlForeground: "oklch(0.159066 0 0)", + toolbarControlHover: "oklch(0.95514 0 0)", + surface: "oklch(1 0 0)", + surfaceRaised: "oklch(0.967153 0 0)", + surfaceOverlay: "oklch(1 0 0)", + text: "oklch(0.159066 0 0)", + textMuted: "oklch(0.478359 0 0)", + border: "oklch(0.930999 0 0)", + input: "oklch(0.885305 0 0)", + focus: "oklch(0.625814 0.205007 254.947)", + accent: "oklch(0.625814 0.205007 254.947)", + accentForeground: "oklch(1 0 0)", + secondary: "oklch(0.967153 0 0)", + secondaryForeground: "oklch(0.159066 0 0)", + muted: "oklch(0.976139 0 0)", + mutedForeground: "oklch(0.478359 0 0)", + placeholder: "oklch(0.650041 0 0)", + secondaryLabel: "oklch(0.478359 0 0)", + iconMuted: "oklch(0.57951 0 0)", + error: "oklch(0.589896 0.213434 27.671)", + errorForeground: "oklch(0.528453 0.198172 28.231)", + errorSurface: "oklch(0.956562 0.018487 17.481)", + warning: "oklch(0.714624 0.157909 67.019)", + // Darkened a touch from the source file (0.567922) to read at 4.5:1 on warningSurface. + warningForeground: "oklch(0.555 0.139863 55.455)", + warningSurface: "oklch(0.970397 0.020872 79.096)", + update: "oklch(0.625814 0.205007 254.947)", + updateForeground: "oklch(0.528649 0.173447 254.975)", + updateSurface: "oklch(0.956017 0.021701 250.213)", + accentSurface: "oklch(0.95514 0 0)", + accentSurfaceForeground: "oklch(0.159066 0 0)", + messageSurface: "oklch(0.967153 0 0)", + messageForeground: "oklch(0.159066 0 0)", + messageAction: "oklch(0.159066 0 0)", + messageActionForeground: "oklch(1 0 0)", + messageActionHover: "oklch(0.305191 0 0)", + codeBackground: "oklch(0.967153 0 0)", + codeForeground: "oklch(0.159066 0 0)", + sidebar: "oklch(0.982118 0 0)", + sidebarForeground: "oklch(0.159066 0 0)", + sidebarMutedForeground: "oklch(0.541706 0 0)", + sidebarControlSurface: "oklch(0.943089 0 0)", + sidebarRowHover: "oklch(0.952131 0 0)", + sidebarRowActive: "oklch(0.930999 0 0)", + sidebarRowSelected: "oklch(0.943089 0 0)", + sidebarBorder: "oklch(0.930999 0 0)", + terminalBackground: "oklch(1 0 0)", + terminalForeground: "oklch(0.159066 0 0)", + terminalCursor: "oklch(0.159066 0 0)", + terminalSelection: "oklch(0.92882 0.034522 256.337)", + terminalScrollbar: "oklch(0.885305 0 0)", + terminalScrollbarHover: "oklch(0.798372 0 0)", + }, + }, + sidebarArtwork: true, +}; + export const T3_CHAT_THEME: ThemeDefinition = { id: "t3-chat", label: "T3 Chat", @@ -884,6 +1020,7 @@ export const IRIS_THEME: ThemeDefinition = { }; export const BUILT_IN_THEMES: ReadonlyArray = [ + CHATGPT_THEME, T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME,