From 115cb376bbe10dbe992f7950e6148dfbfc281a82 Mon Sep 17 00:00:00 2001 From: PylotLight Date: Tue, 26 May 2026 02:01:59 +1000 Subject: [PATCH 1/3] Add transparecy option for UI --- .../desktop/electron/app-store-persistence.ts | 2 + apps/desktop/electron/app-store.ts | 16 +++ apps/desktop/electron/main.ts | 14 ++- apps/desktop/electron/preload.ts | 2 + apps/desktop/src/App.tsx | 10 ++ apps/desktop/src/desktop-state.ts | 2 + apps/desktop/src/ipc.ts | 2 + .../src/settings-appearance-section.tsx | 42 ++++++-- apps/desktop/src/settings-view.tsx | 6 ++ apps/desktop/src/styles/base.css | 79 +++++++++++--- apps/desktop/src/styles/main.css | 101 ++++++++++-------- apps/desktop/src/styles/sidebar.css | 29 +++-- 12 files changed, 229 insertions(+), 76 deletions(-) diff --git a/apps/desktop/electron/app-store-persistence.ts b/apps/desktop/electron/app-store-persistence.ts index 919a1b13..c25053fa 100644 --- a/apps/desktop/electron/app-store-persistence.ts +++ b/apps/desktop/electron/app-store-persistence.ts @@ -26,6 +26,7 @@ export interface PersistedUiState { readonly appGlobalModelSettings?: ModelSettingsSnapshot; readonly sidebarCollapsed?: boolean; readonly allowMultiple?: boolean; + readonly enableTransparency?: boolean; } export interface LegacyPersistedUiState extends PersistedUiState { @@ -74,6 +75,7 @@ export async function readPersistedUiState(uiStateFilePath: string): Promise { + await this.initialize(); + if (this.state.enableTransparency === enabled) { + return structuredClone(this.state); + } + this.state = { + ...this.state, + enableTransparency: enabled, + lastError: undefined, + revision: this.state.revision + 1, + }; + await this.persistUiState(); + return this.emit(); + } + async setAllowMultiple(allowMultiple: boolean): Promise { await this.initialize(); if (this.state.allowMultiple === allowMultiple) { @@ -1707,6 +1722,7 @@ export class DesktopAppStore implements AppStoreInternals { appGlobalModelSettings: hasStoredModelSettings(this.state.globalModelSettings) ? this.state.globalModelSettings : undefined, sidebarCollapsed: this.state.sidebarCollapsed || undefined, allowMultiple: this.state.allowMultiple, + enableTransparency: this.state.enableTransparency, }; await writePersistedUiState(this.uiStateFilePath, payload); diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 25530284..8b269ab1 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -119,13 +119,16 @@ function readClipboardImageAttachment(): ComposerImageAttachment | null { function createWindow(): BrowserWindow { const backgroundTestMode = windowTestMode === "background"; + const enableTransparency = store ? store.state.enableTransparency : false; const window = new BrowserWindow({ width: 1480, height: 980, minWidth: 1200, minHeight: 760, - backgroundColor: "#f3f4f8", + transparent: true, + vibrancy: process.platform === "darwin" && enableTransparency ? "under-window" : undefined, titleBarStyle: "hiddenInset", + backgroundColor: '#00000000', trafficLightPosition: { x: 18, y: 18 }, show: false, icon: appIcon, @@ -593,6 +596,15 @@ app.whenReady().then(async () => { ipcMain.handle(desktopIpc.setAllowMultiple, (_event, allowMultiple: boolean) => store.setAllowMultiple(allowMultiple), ); + ipcMain.handle(desktopIpc.setAppearanceEffects, async (_event, enabled: boolean) => { + const nextState = await store.setEnableTransparency(enabled); + if (mainWindow && !mainWindow.isDestroyed()) { + if (process.platform === "darwin") { + mainWindow.setVibrancy(enabled ? "under-window" : null); + } + } + return nextState; + }); ipcMain.handle(desktopIpc.terminalEnsurePanel, (event, workspaceId: string, terminalScopeId: string, size) => { return getTerminalService().ensurePanel(event.sender, workspaceId, terminalScopeId, size); }); diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8162375f..5fd9c7e8 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -184,6 +184,8 @@ contextBridge.exposeInMainWorld("piApp", { ipcRenderer.invoke(desktopIpc.setIntegratedTerminalShell, shellPath) as Promise, setAllowMultiple: (allowMultiple: boolean) => ipcRenderer.invoke(desktopIpc.setAllowMultiple, allowMultiple) as Promise, + setEnableTransparency: (enabled: boolean) => + ipcRenderer.invoke(desktopIpc.setAppearanceEffects, enabled) as Promise, ensureTerminalPanel: (workspaceId: string, terminalScopeId: string, size?: Partial) => ipcRenderer.invoke(desktopIpc.terminalEnsurePanel, workspaceId, terminalScopeId, size) as Promise, createTerminalSession: (workspaceId: string, terminalScopeId: string, size?: Partial) => diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 689e5a17..8c19302e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -240,6 +240,12 @@ export default function App() { return unsub; }, []); + useEffect(() => { + if (snapshot) { + document.documentElement.classList.toggle("enable-transparency", snapshot.enableTransparency); + } + }, [snapshot?.enableTransparency]); + useEffect(() => { const piApi = window.piApp; if (!piApi?.onNotificationPermissionStatusChanged) { @@ -1917,6 +1923,7 @@ export default function App() { integratedTerminalShell={snapshot.integratedTerminalShell} allowMultiple={snapshot.allowMultiple} themeMode={themeMode} + enableTransparency={snapshot.enableTransparency} onLoginProvider={handleLoginProvider} onLogoutProvider={handleLogoutProvider} onSetProviderApiKey={handleSetProviderApiKey} @@ -1932,6 +1939,9 @@ export default function App() { onSetThemeMode={handleSetThemeMode} onSetThinkingLevel={handleSetThinkingLevel} onToggleSkillCommands={handleToggleSkillCommands} + onSetEnableTransparency={(enabled) => { + void updateSnapshot(api, setSnapshot, () => api.setEnableTransparency(enabled)); + }} /> ); diff --git a/apps/desktop/src/desktop-state.ts b/apps/desktop/src/desktop-state.ts index 4d95764f..89352cc2 100644 --- a/apps/desktop/src/desktop-state.ts +++ b/apps/desktop/src/desktop-state.ts @@ -175,6 +175,7 @@ export interface DesktopAppState { readonly modelSettingsScopeMode: ModelSettingsScopeMode; readonly globalModelSettings: ModelSettingsSnapshot; readonly sidebarCollapsed: boolean; + readonly enableTransparency: boolean; readonly revision: number; readonly lastError?: string; } @@ -219,6 +220,7 @@ export function createEmptyDesktopAppState(): DesktopAppState { enabledModelPatterns: [], }, sidebarCollapsed: false, + enableTransparency: false, revision: 0, }; } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 29671590..a1a036fe 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -71,6 +71,7 @@ export const desktopIpc = { setNotificationPreferences: "pi-gui:set-notification-preferences", setIntegratedTerminalShell: "pi-gui:set-integrated-terminal-shell", setAllowMultiple: "pi-gui:set-allow-multiple", + setAppearanceEffects: "pi-gui:set-enable-transparency", terminalEnsurePanel: "pi-gui:terminal-ensure-panel", terminalCreateSession: "pi-gui:terminal-create-session", terminalSetActiveSession: "pi-gui:terminal-set-active-session", @@ -275,6 +276,7 @@ export interface PiDesktopApi { setNotificationPreferences(preferences: Partial): Promise; setIntegratedTerminalShell(shell: string): Promise; setAllowMultiple(allowMultiple: boolean): Promise; + setEnableTransparency(enabled: boolean): Promise; ensureTerminalPanel( workspaceId: string, terminalScopeId: string, diff --git a/apps/desktop/src/settings-appearance-section.tsx b/apps/desktop/src/settings-appearance-section.tsx index 397b508f..e874ae4d 100644 --- a/apps/desktop/src/settings-appearance-section.tsx +++ b/apps/desktop/src/settings-appearance-section.tsx @@ -4,6 +4,8 @@ import { SettingsGroup, SettingsRow } from "./settings-utils"; interface SettingsAppearanceSectionProps { readonly themeMode: ThemeMode; readonly onSetThemeMode: (mode: ThemeMode) => void; + readonly enableTransparency: boolean; + readonly onSetEnableTransparency: (enabled: boolean) => void; } const THEME_OPTIONS: { mode: ThemeMode; label: string; description: string }[] = [ @@ -12,19 +14,39 @@ const THEME_OPTIONS: { mode: ThemeMode; label: string; description: string }[] = { mode: "dark", label: "Dark", description: "Always use the dark theme" }, ]; -export function SettingsAppearanceSection({ themeMode, onSetThemeMode }: SettingsAppearanceSectionProps) { +export function SettingsAppearanceSection({ + themeMode, + onSetThemeMode, + enableTransparency, + onSetEnableTransparency, +}: SettingsAppearanceSectionProps) { return ( - - {THEME_OPTIONS.map((option) => ( - + <> + + {THEME_OPTIONS.map((option) => ( + + onSetThemeMode(option.mode)} + /> + + ))} + + + + onSetThemeMode(option.mode)} + type="checkbox" + checked={enableTransparency} + onChange={(e) => onSetEnableTransparency(e.target.checked)} /> - ))} - + + ); } diff --git a/apps/desktop/src/settings-view.tsx b/apps/desktop/src/settings-view.tsx index 648ad33b..03b99fa9 100644 --- a/apps/desktop/src/settings-view.tsx +++ b/apps/desktop/src/settings-view.tsx @@ -21,6 +21,7 @@ interface SettingsViewProps { readonly integratedTerminalShell: string; readonly allowMultiple: boolean; readonly themeMode: "system" | "light" | "dark"; + readonly enableTransparency: boolean; readonly onSetModelSettingsScopeMode: (mode: ModelSettingsScopeMode) => void; readonly onSetDefaultModel: (provider: string, modelId: string) => void; readonly onSetThinkingLevel: (thinkingLevel: RuntimeSettingsSnapshot["defaultThinkingLevel"]) => void; @@ -36,6 +37,7 @@ interface SettingsViewProps { readonly onRequestNotificationPermission: () => void; readonly onOpenSystemNotificationSettings: () => void; readonly onSetThemeMode: (mode: "system" | "light" | "dark") => void; + readonly onSetEnableTransparency: (enabled: boolean) => void; } export function SettingsView({ @@ -49,6 +51,7 @@ export function SettingsView({ integratedTerminalShell, allowMultiple, themeMode, + enableTransparency, onSetModelSettingsScopeMode, onSetDefaultModel, onSetThinkingLevel, @@ -64,6 +67,7 @@ export function SettingsView({ onRequestNotificationPermission, onOpenSystemNotificationSettings, onSetThemeMode, + onSetEnableTransparency, }: SettingsViewProps) { if (!workspace && section !== "general" && section !== "notifications" && section !== "appearance") { return ( @@ -95,6 +99,8 @@ export function SettingsView({ ) : null} diff --git a/apps/desktop/src/styles/base.css b/apps/desktop/src/styles/base.css index 5e03e309..5fa35f31 100644 --- a/apps/desktop/src/styles/base.css +++ b/apps/desktop/src/styles/base.css @@ -1,12 +1,19 @@ :root { color-scheme: light; --window: #eceef3; + --window-glass: rgba(236, 238, 243, 0.6); --sidebar: #f4f5f8; + --sidebar-glass: rgba(244, 245, 248, 0.7); --main: #f8f8fb; + --main-glass: rgba(248, 248, 251, 0.6); --surface: #ffffff; + --surface-glass: rgba(255, 255, 255, 0.8); --surface-muted: #f1f3f7; + --surface-muted-glass: rgba(241, 243, 247, 0.7); --line: #dde1ea; + --line-glass: rgba(221, 225, 234, 0.4); --line-strong: #d2d7e2; + --line-strong-glass: rgba(210, 215, 234, 0.6); --text: #39435b; --text-strong: #1f2638; --muted: #747d93; @@ -19,6 +26,10 @@ --accent: #6a55f2; --error: #c45666; --error-ink: #8f3040; + --surface-overlay: rgba(255, 255, 255, 0.55); + --surface-overlay-hover: rgba(255, 255, 255, 0.62); + --surface-overlay-border: rgba(210, 215, 226, 0.85); + --surface-overlay-muted: rgba(255, 255, 255, 0.45); --font-ui: ui-sans-serif, -apple-system, @@ -43,17 +54,33 @@ --button-primary-disabled-bg: #b7becc; --button-primary-disabled-border: #b7becc; --button-primary-disabled-ink: #ffffff; + + /* Glass Primitives */ + --glass-blur: blur(12px); + --glass-saturation: saturate(180%); + --glass-border: rgba(255, 255, 255, 0.2); + --shadow-sm: 0 2px 8px rgba(20, 27, 45, 0.06); + --shadow-md: 0 8px 24px rgba(20, 27, 45, 0.1); + --shadow-lg: 0 16px 48px rgba(20, 27, 45, 0.14); + --shadow-xl: 0 32px 80px rgba(20, 27, 45, 0.2); } :root.dark { color-scheme: dark; --window: #1a1b1e; + --window-glass: rgba(26, 27, 30, 0.6); --sidebar: #202124; + --sidebar-glass: rgba(32, 33, 36, 0.7); --main: #1e1f22; + --main-glass: rgba(30, 31, 34, 0.6); --surface: #2b2d31; + --surface-glass: rgba(43, 45, 49, 0.8); --surface-muted: #232428; + --surface-muted-glass: rgba(35, 36, 40, 0.7); --line: #3a3c42; + --line-glass: rgba(58, 60, 66, 0.4); --line-strong: #4a4d55; + --line-strong-glass: rgba(74, 77, 85, 0.6); --text: #d4d4d8; --text-strong: #f4f4f5; --muted: #8b8d94; @@ -66,6 +93,10 @@ --accent: #7c6bf5; --error: #e05467; --error-ink: #f87171; + --surface-overlay: rgba(255, 255, 255, 0.06); + --surface-overlay-hover: rgba(255, 255, 255, 0.1); + --surface-overlay-border: rgba(255, 255, 255, 0.12); + --surface-overlay-muted: rgba(255, 255, 255, 0.04); --border: var(--line); --ink: var(--text); --ink-strong: var(--text-strong); @@ -77,6 +108,26 @@ --button-primary-disabled-bg: #5f636b; --button-primary-disabled-border: #5f636b; --button-primary-disabled-ink: #f4f4f5; + + --glass-border: rgba(0, 0, 0, 0.2); + --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.5); + --shadow-xl: 0 32px 80px rgba(0, 0, 0, 0.6); +} + +.enable-transparency { + --window: transparent !important; + --sidebar: transparent !important; + --main: transparent !important; + --surface: var(--surface-glass) !important; + --surface-muted: var(--surface-muted-glass) !important; + --line: var(--line-glass) !important; + --line-strong: var(--line-strong-glass) !important; +} + +.enable-transparency body { + backdrop-filter: var(--glass-blur) var(--glass-saturation); } * { @@ -89,12 +140,12 @@ body, width: 100%; height: 100%; margin: 0; + background: transparent; } body { font-family: var(--font-ui); color: var(--text-strong); - background: var(--window); font-size: 15px; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; @@ -166,7 +217,7 @@ svg { padding: 16px; border-radius: 12px; border: 1px dashed var(--line-strong); - background: rgba(255, 255, 255, 0.45); + background: var(--surface-overlay-muted); } .empty-state h2 { @@ -202,7 +253,7 @@ svg { } .button--ghost { - background: rgba(255, 255, 255, 0.55); + background: var(--surface-overlay); border-color: var(--line); } @@ -212,8 +263,8 @@ svg { } .button:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.62); - border-color: rgba(210, 215, 226, 0.85); + background: var(--surface-overlay-hover); + border-color: var(--surface-overlay-border); } .button--primary:hover:not(:disabled) { @@ -250,8 +301,12 @@ svg { font-size: 12px; font-weight: 500; color: var(--muted-strong); - background: rgba(255, 255, 255, 0.8); - border: 1px solid #d9deea; + background: var(--surface-glass); + border: 1px solid var(--line); +} + +.enable-transparency .meta-chip { + backdrop-filter: var(--glass-blur) var(--glass-saturation); } .meta-chip--path { @@ -263,19 +318,19 @@ svg { /* Dark mode overrides for hardcoded light colors */ :root.dark .empty-state { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-overlay-muted); } :root.dark .button--ghost { - background: rgba(255, 255, 255, 0.06); + background: var(--surface-overlay); } :root.dark .button:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.12); + background: var(--surface-overlay-hover); + border-color: var(--surface-overlay-border); } :root.dark .meta-chip { - background: rgba(255, 255, 255, 0.06); + background: var(--surface-glass); border-color: var(--line); } diff --git a/apps/desktop/src/styles/main.css b/apps/desktop/src/styles/main.css index bbdfc310..67c6ccba 100644 --- a/apps/desktop/src/styles/main.css +++ b/apps/desktop/src/styles/main.css @@ -11,7 +11,7 @@ } .main { - background: #fbfbfd; + background: var(--main); display: grid; grid-template-rows: auto 1fr auto; min-width: 0; @@ -50,7 +50,7 @@ align-items: center; justify-content: space-between; gap: 12px; - border-bottom: 1px solid #e4e7ef; + border-bottom: 1px solid var(--line); background: rgba(251, 251, 253, 0.94); } @@ -74,8 +74,8 @@ min-height: 26px; padding: 0 10px; border-radius: 999px; - border: 1px solid #d7dce8; - background: #ffffff; + border: 1px solid var(--line); + background: var(--surface); color: var(--muted-strong); font-size: 12px; font-weight: 560; @@ -145,7 +145,7 @@ gap: 8px; min-width: max-content; padding: 6px 9px; - border: 1px solid #dde3ef; + border: 1px solid var(--line); border-radius: 8px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 10px 28px rgba(20, 27, 45, 0.12); @@ -162,7 +162,7 @@ .shortcut-tooltip kbd { min-width: 28px; padding: 1px 5px 2px; - border: 1px solid #dce2ee; + border: 1px solid var(--line); border-radius: 5px; background: #f7f9fc; color: #526078; @@ -339,7 +339,7 @@ display: block; padding: 10px 14px; border-radius: 999px; - border: 1px solid #d7dce8; + border: 1px solid var(--line); background: rgba(255, 255, 255, 0.96); box-shadow: 0 14px 32px rgba(20, 27, 45, 0.12); color: var(--text-strong); @@ -392,8 +392,8 @@ width: min(620px, 100%); padding: 13px 17px; border-radius: 16px; - border: 1px solid #dfe4ee; - background: #f5f6fa; + border: 1px solid var(--line); + background: var(--main); } .timeline-item__attachments { @@ -404,8 +404,8 @@ } .timeline-item__attachment { - border: 1px solid #dfe4ee; - background: #edf1f7; + border: 1px solid var(--line); + background: var(--surface-muted); } .timeline-item__attachment--image { @@ -423,7 +423,7 @@ gap: 10px; padding: 10px 12px; border-radius: 12px; - background: #f8f9fc; + background: var(--main); } .timeline-item__attachment-icon { @@ -1110,10 +1110,10 @@ position: relative; border-radius: 18px; padding: 12px 14px 11px; - border: 1px solid #dfe4ee; - background: #ffffff; + border: 1px solid var(--line); + background: var(--surface); width: 100%; - box-shadow: 0 10px 24px rgba(24, 31, 48, 0.04); + box-shadow: var(--shadow-md); overflow: visible; transition: border-color 0.16s ease, box-shadow 0.16s ease, background-color 0.16s ease; } @@ -1423,8 +1423,8 @@ height: 32px; border-radius: 10px; color: var(--muted-soft); - border: 1px solid #dfe4ee; - background: #f6f7fb; + border: 1px solid var(--line); + background: var(--surface-muted); } .composer__attachments { @@ -1447,8 +1447,8 @@ gap: 12px; padding: 10px 12px; border-radius: 14px; - border: 1px solid #dfe4ee; - background: #f7f8fc; + border: 1px solid var(--line); + background: var(--surface-muted); color: var(--muted-strong); font-size: 13px; font-weight: 560; @@ -1473,8 +1473,8 @@ gap: 10px; padding: 12px 14px; border-radius: 16px; - border: 1px solid #dfe4ee; - background: linear-gradient(180deg, #fbfbfd 0%, #f6f7fb 100%); + border: 1px solid var(--line); + background: linear-gradient(180deg, var(--main) 0%, var(--surface-muted) 100%); } .queued-composer-message--editing { @@ -1521,8 +1521,8 @@ max-width: min(220px, 100%); padding: 5px 9px 5px 5px; border-radius: 999px; - border: 1px solid #dfe4ee; - background: rgba(255, 255, 255, 0.7); + border: 1px solid var(--line); + background: var(--surface-overlay); } .queued-composer-attachment__preview { @@ -1567,8 +1567,8 @@ max-width: min(280px, 100%); padding: 6px 10px 6px 6px; border-radius: 999px; - border: 1px solid #dfe4ee; - background: #f6f7fb; + border: 1px solid var(--line); + background: var(--surface-muted); } .composer-attachment__preview { @@ -1615,7 +1615,7 @@ min-height: 100vh; display: grid; grid-template-columns: 280px minmax(0, 1fr); - background: #f3f4f8; + background: var(--window); } .secondary-surface__sidebar { @@ -1623,8 +1623,8 @@ align-content: start; gap: 18px; padding: 56px 18px 20px; - border-right: 1px solid #dde3ee; - background: #eef1f7; + border-right: 1px solid var(--line); + background: var(--sidebar); } .secondary-surface__back { @@ -1699,9 +1699,9 @@ gap: 4px; padding: 6px; border-radius: 14px; - border: 1px solid #dfe4ee; - background: rgba(252, 252, 254, 0.98); - box-shadow: 0 20px 48px rgba(20, 27, 45, 0.14); + border: 1px solid var(--line); + background: var(--surface); + box-shadow: var(--shadow-lg); position: relative; z-index: 2; max-height: min(420px, 48vh); @@ -1913,13 +1913,28 @@ line-height: 1.4; } -/* Mention menu */ +.enable-transparency .composer__surface, +.enable-transparency .tree-modal, +.enable-transparency .slash-menu, +.enable-transparency .mention-menu, +.enable-transparency .secondary-surface__sidebar { + backdrop-filter: var(--glass-blur) var(--glass-saturation); +} + +.enable-transparency .topbar { + background: var(--surface-glass) !important; + backdrop-filter: var(--glass-blur) var(--glass-saturation); +} + +.enable-transparency .composer { + background: transparent !important; +} .mention-menu { - border: 1px solid var(--border); + border: 1px solid var(--line); border-radius: 8px; background: var(--surface); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); + box-shadow: var(--shadow-md); max-height: 320px; overflow-y: auto; padding: 4px; @@ -2092,7 +2107,7 @@ .settings-disclosure { - border-top: 1px solid #edf0f6; + border-top: 1px solid var(--line); padding-top: 12px; } @@ -2182,9 +2197,9 @@ } .settings-group { - border: 1px solid #dfe4ee; + border: 1px solid var(--line); border-radius: 18px; - background: #fff; + background: var(--surface); } .settings-group > :first-child { @@ -2200,7 +2215,7 @@ } .settings-group > * + * { - border-top: 1px solid #edf0f6; + border-top: 1px solid var(--line); } .settings-row { @@ -2414,8 +2429,8 @@ .skill-detail { padding: 16px 18px; border-radius: 18px; - border: 1px solid #dfe4ee; - background: #fff; + border: 1px solid var(--line); + background: var(--surface); } .skill-card { @@ -2696,9 +2711,9 @@ gap: 16px; padding: 22px; border-radius: 24px; - background: #fff; - border: 1px solid #dfe4ee; - box-shadow: 0 28px 80px rgba(22, 29, 41, 0.18); + background: var(--surface); + border: 1px solid var(--line); + box-shadow: var(--shadow-xl); overflow: hidden; } diff --git a/apps/desktop/src/styles/sidebar.css b/apps/desktop/src/styles/sidebar.css index e7e808fa..093931ee 100644 --- a/apps/desktop/src/styles/sidebar.css +++ b/apps/desktop/src/styles/sidebar.css @@ -13,6 +13,7 @@ gap: 0; padding: 0; position: relative; + background: var(--window); } .shell--loading { @@ -33,8 +34,12 @@ display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; - background: #f6f7fa; - border-right: 1px solid #dde1ea; + background: var(--sidebar); + border-right: 1px solid var(--line); +} + +.enable-transparency .sidebar { + backdrop-filter: var(--glass-blur) var(--glass-saturation); } .sidebar__top, @@ -73,8 +78,8 @@ } .sidebar__nav-item--active { - background: #eef1f6; - border-color: #e1e5ee; + background: var(--surface-overlay); + border-color: var(--line); color: var(--text-strong); } @@ -84,8 +89,8 @@ min-height: 36px; padding: 8px 12px; border-radius: 10px; - background: #eef1f6; - border: 1px solid #e1e5ee; + background: var(--surface-muted); + border: 1px solid var(--line); color: var(--text-strong); font-size: 14px; font-weight: 600; @@ -448,8 +453,8 @@ } .session-row--active { - background: #ffffff; - border-color: #d7dce8; + background: var(--surface-overlay); + border-color: var(--line); color: var(--text-strong); box-shadow: 0 1px 2px rgba(20, 27, 45, 0.04); } @@ -663,8 +668,8 @@ } .sidebar__footer { - border-top: 1px solid #dde2eb; - background: rgba(255, 255, 255, 0.2); + border-top: 1px solid var(--line); + background: var(--surface-overlay-muted); } .sidebar__settings { @@ -707,6 +712,10 @@ border-right-color: var(--line); } +.enable-transparency.dark .sidebar { + backdrop-filter: var(--glass-blur) var(--glass-saturation); +} + :root.dark .sidebar__nav-item--active { background: rgba(255, 255, 255, 0.06); border-color: var(--line); From 54a5fdc85c79596f3a60773e3eb568bf6a88b711 Mon Sep 17 00:00:00 2001 From: Matthew Lam Date: Fri, 29 May 2026 11:32:07 -0400 Subject: [PATCH 2/3] Harden transparency setting restore --- apps/desktop/electron/app-store.ts | 2 + apps/desktop/electron/main.ts | 6 +- apps/desktop/electron/preload.ts | 2 +- apps/desktop/src/ipc.ts | 2 +- .../src/settings-appearance-section.tsx | 7 +- .../tests/core/settings-appearance.spec.ts | 64 +++++++++++++++++++ 6 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/tests/core/settings-appearance.spec.ts diff --git a/apps/desktop/electron/app-store.ts b/apps/desktop/electron/app-store.ts index 111d4dd2..93048589 100644 --- a/apps/desktop/electron/app-store.ts +++ b/apps/desktop/electron/app-store.ts @@ -777,6 +777,7 @@ export class DesktopAppStore implements AppStoreInternals { workspaceOrder: persisted.workspaceOrder ?? [], sidebarCollapsed: persisted.sidebarCollapsed ?? this.state.sidebarCollapsed, allowMultiple: persisted.allowMultiple ?? this.state.allowMultiple, + enableTransparency: persisted.enableTransparency ?? this.state.enableTransparency, }; await this.migrateLegacyPersistence(persisted); this.sessionState.lastViewedAtBySession.clear(); @@ -828,6 +829,7 @@ export class DesktopAppStore implements AppStoreInternals { this.state = { ...createEmptyDesktopAppState(), allowMultiple: persisted.allowMultiple ?? false, + enableTransparency: persisted.enableTransparency ?? false, lastError: error instanceof Error ? error.message : String(error), revision: 1, }; diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8b269ab1..9028dd9e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -125,10 +125,10 @@ function createWindow(): BrowserWindow { height: 980, minWidth: 1200, minHeight: 760, - transparent: true, + transparent: enableTransparency, vibrancy: process.platform === "darwin" && enableTransparency ? "under-window" : undefined, titleBarStyle: "hiddenInset", - backgroundColor: '#00000000', + backgroundColor: enableTransparency ? "#00000000" : "#f3f4f8", trafficLightPosition: { x: 18, y: 18 }, show: false, icon: appIcon, @@ -596,7 +596,7 @@ app.whenReady().then(async () => { ipcMain.handle(desktopIpc.setAllowMultiple, (_event, allowMultiple: boolean) => store.setAllowMultiple(allowMultiple), ); - ipcMain.handle(desktopIpc.setAppearanceEffects, async (_event, enabled: boolean) => { + ipcMain.handle(desktopIpc.setEnableTransparency, async (_event, enabled: boolean) => { const nextState = await store.setEnableTransparency(enabled); if (mainWindow && !mainWindow.isDestroyed()) { if (process.platform === "darwin") { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 5fd9c7e8..c0ccddb1 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -185,7 +185,7 @@ contextBridge.exposeInMainWorld("piApp", { setAllowMultiple: (allowMultiple: boolean) => ipcRenderer.invoke(desktopIpc.setAllowMultiple, allowMultiple) as Promise, setEnableTransparency: (enabled: boolean) => - ipcRenderer.invoke(desktopIpc.setAppearanceEffects, enabled) as Promise, + ipcRenderer.invoke(desktopIpc.setEnableTransparency, enabled) as Promise, ensureTerminalPanel: (workspaceId: string, terminalScopeId: string, size?: Partial) => ipcRenderer.invoke(desktopIpc.terminalEnsurePanel, workspaceId, terminalScopeId, size) as Promise, createTerminalSession: (workspaceId: string, terminalScopeId: string, size?: Partial) => diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index a1a036fe..0e126a67 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -71,7 +71,7 @@ export const desktopIpc = { setNotificationPreferences: "pi-gui:set-notification-preferences", setIntegratedTerminalShell: "pi-gui:set-integrated-terminal-shell", setAllowMultiple: "pi-gui:set-allow-multiple", - setAppearanceEffects: "pi-gui:set-enable-transparency", + setEnableTransparency: "pi-gui:set-enable-transparency", terminalEnsurePanel: "pi-gui:terminal-ensure-panel", terminalCreateSession: "pi-gui:terminal-create-session", terminalSetActiveSession: "pi-gui:terminal-set-active-session", diff --git a/apps/desktop/src/settings-appearance-section.tsx b/apps/desktop/src/settings-appearance-section.tsx index e874ae4d..2dad66c5 100644 --- a/apps/desktop/src/settings-appearance-section.tsx +++ b/apps/desktop/src/settings-appearance-section.tsx @@ -37,13 +37,14 @@ export function SettingsAppearanceSection({ onSetEnableTransparency(e.target.checked)} + onChange={(event) => onSetEnableTransparency(event.currentTarget.checked)} /> diff --git a/apps/desktop/tests/core/settings-appearance.spec.ts b/apps/desktop/tests/core/settings-appearance.spec.ts new file mode 100644 index 00000000..e89c5c48 --- /dev/null +++ b/apps/desktop/tests/core/settings-appearance.spec.ts @@ -0,0 +1,64 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; +import { + desktopShortcut, + getDesktopState, + launchDesktop, + makeUserDataDir, + makeWorkspace, + waitForWorkspaceByPath, +} from "../helpers/electron-app"; + +test("toggles and restores window transparency", async () => { + const userDataDir = await makeUserDataDir(); + const workspacePath = await makeWorkspace("appearance-transparency"); + let harness = await launchDesktop(userDataDir, { + initialWorkspaces: [workspacePath], + testMode: "background", + }); + + try { + const window = await harness.firstWindow(); + await waitForWorkspaceByPath(window, workspacePath); + await expect.poll(() => hasTransparencyClass(window)).toBe(false); + + await window.keyboard.press(desktopShortcut(",")); + await expect(window.getByTestId("settings-surface")).toBeVisible(); + await window.getByRole("button", { name: "Appearance", exact: true }).click(); + + const transparencyToggle = window.getByLabel("Window transparency"); + await expect(transparencyToggle).not.toBeChecked(); + await transparencyToggle.click(); + await expect.poll(async () => (await getDesktopState(window)).enableTransparency).toBe(true); + await expect.poll(() => hasTransparencyClass(window)).toBe(true); + await expect + .poll(async () => { + const persisted = JSON.parse(await readFile(join(userDataDir, "ui-state.json"), "utf8")) as { + readonly enableTransparency?: unknown; + }; + return persisted.enableTransparency; + }) + .toBe(true); + } finally { + await harness.close(); + } + + harness = await launchDesktop(userDataDir, { + initialWorkspaces: [workspacePath], + testMode: "background", + }); + + try { + const window = await harness.firstWindow(); + await waitForWorkspaceByPath(window, workspacePath); + await expect.poll(async () => (await getDesktopState(window)).enableTransparency).toBe(true); + await expect.poll(() => hasTransparencyClass(window)).toBe(true); + } finally { + await harness.close(); + } +}); + +async function hasTransparencyClass(window: { evaluate(pageFunction: () => R): Promise }): Promise { + return window.evaluate(() => document.documentElement.classList.contains("enable-transparency")); +} From 5ea22d7c2d23c583ea011c085cc434b71054c9cd Mon Sep 17 00:00:00 2001 From: Matthew Lam Date: Fri, 29 May 2026 11:49:05 -0400 Subject: [PATCH 3/3] Stabilize desktop core lane --- apps/desktop/electron/app-store.ts | 29 +++++++-- apps/desktop/src/App.tsx | 64 ++++++------------- .../tests/core/integrated-terminal.spec.ts | 9 ++- 3 files changed, 53 insertions(+), 49 deletions(-) diff --git a/apps/desktop/electron/app-store.ts b/apps/desktop/electron/app-store.ts index 93048589..124b4d48 100644 --- a/apps/desktop/electron/app-store.ts +++ b/apps/desktop/electron/app-store.ts @@ -147,6 +147,7 @@ export class DesktopAppStore implements AppStoreInternals { private readonly getWindow: () => BrowserWindow | null; private persistUiStateTimer: NodeJS.Timeout | undefined; private readonly transcriptPersistTimers = new Map(); + private readonly restoredSelectedSessionKeysAwaitingSelection = new Set(); private initPromise: Promise | undefined; private selectionEpoch = 0; private refreshStateDepth = 0; @@ -823,8 +824,13 @@ export class DesktopAppStore implements AppStoreInternals { clearLastError: true, refreshWorktrees: true, hydrateSelectedSession: false, + markSelectedSessionViewed: false, }); - this.startSelectedSessionHydration(this.selectedSessionRef()); + const restoredSessionRef = this.selectedSessionRef(); + if (restoredSessionRef && persisted.selectedWorkspaceId && persisted.selectedSessionId) { + this.restoredSelectedSessionKeysAwaitingSelection.add(sessionKey(restoredSessionRef)); + } + this.startSelectedSessionHydration(restoredSessionRef, { markViewed: false }); } catch (error) { this.state = { ...createEmptyDesktopAppState(), @@ -1910,6 +1916,7 @@ export class DesktopAppStore implements AppStoreInternals { } private applyFastSessionSelection(sessionRef: SessionRef): DesktopAppState { + this.restoredSelectedSessionKeysAwaitingSelection.delete(sessionKey(sessionRef)); this.state = { ...this.state, selectedWorkspaceId: sessionRef.workspaceId, @@ -1931,7 +1938,11 @@ export class DesktopAppStore implements AppStoreInternals { return snapshot; } - private async hydrateSelectedSessionAfterSelection(sessionRef: SessionRef, selectionEpoch: number): Promise { + private async hydrateSelectedSessionAfterSelection( + sessionRef: SessionRef, + selectionEpoch: number, + options: { readonly markViewed?: boolean } = {}, + ): Promise { const runtimeMissing = !this.runtimeByWorkspace.has(sessionRef.workspaceId); const [snapshot] = await Promise.all([ this.ensureSessionReady(sessionRef), @@ -1950,19 +1961,24 @@ export class DesktopAppStore implements AppStoreInternals { this.clearSessionError(sessionRef); this.state = this.syncSelectedSessionHydrationState(this.state, sessionRef, snapshot, runtimeByWorkspace); - this.markSessionViewed(sessionRef); + if (options.markViewed ?? true) { + this.markSessionViewed(sessionRef); + } this.schedulePersistUiState(); this.emit(); this.publishSelectedTranscriptFor(sessionRef); } - private startSelectedSessionHydration(sessionRef: SessionRef | undefined): void { + private startSelectedSessionHydration( + sessionRef: SessionRef | undefined, + options: { readonly markViewed?: boolean } = {}, + ): void { if (!sessionRef) { return; } const selectionEpoch = ++this.selectionEpoch; - void this.hydrateSelectedSessionAfterSelection(sessionRef, selectionEpoch).catch((error: unknown) => { + void this.hydrateSelectedSessionAfterSelection(sessionRef, selectionEpoch, options).catch((error: unknown) => { void this.handleSelectedSessionHydrationError(sessionRef, selectionEpoch, error); }); } @@ -2002,6 +2018,9 @@ export class DesktopAppStore implements AppStoreInternals { if (!isSessionActivelyViewed(this.state, sessionRef, this.getWindow())) { return false; } + if (this.restoredSelectedSessionKeysAwaitingSelection.has(sessionKey(sessionRef))) { + return false; + } return this.markSessionViewed(sessionRef); } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8c19302e..47b248e0 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -198,8 +198,8 @@ export default function App() { const handledComposerSyncNonceRef = useRef(0); const [showJumpToLatest, setShowJumpToLatest] = useState(false); const [showDiffPanel, setShowDiffPanel] = useState(false); - const [openTerminalSessionKeys, setOpenTerminalSessionKeys] = useState>(() => new Set()); - const [takeoverTerminalSessionKeys, setTakeoverTerminalSessionKeys] = useState>(() => new Set()); + const [openTerminalSessionKey, setOpenTerminalSessionKey] = useState(""); + const [takeoverTerminalSessionKey, setTakeoverTerminalSessionKey] = useState(""); const [terminalHeight, setTerminalHeight] = useState(340); const [diffFileRequest, setDiffFileRequest] = useState(null); const [timelinePaneMountVersion, setTimelinePaneMountVersion] = useState(0); @@ -378,8 +378,8 @@ export default function App() { const editingQueuedMessageId = snapshot?.editingQueuedMessageId; const runningLabel = useRunningLabel(selectedSession?.status === "running" ? selectedSession.runningSince : undefined); const selectedSessionKey = selectedWorkspace && selectedSession ? `${selectedWorkspace.id}:${selectedSession.id}` : ""; - const isTerminalVisibleForSelectedThread = Boolean(selectedSessionKey) && openTerminalSessionKeys.has(selectedSessionKey); - const isTerminalTakeoverForSelectedThread = Boolean(selectedSessionKey) && takeoverTerminalSessionKeys.has(selectedSessionKey); + const isTerminalVisibleForSelectedThread = Boolean(selectedSessionKey) && openTerminalSessionKey === selectedSessionKey; + const isTerminalTakeoverForSelectedThread = Boolean(selectedSessionKey) && takeoverTerminalSessionKey === selectedSessionKey; const activeTranscript = selectedTranscript && selectedWorkspace && @@ -400,10 +400,14 @@ export default function App() { : []; useEffect(() => { if (snapshot && snapshot.workspaces.length === 0) { - setOpenTerminalSessionKeys(new Set()); - setTakeoverTerminalSessionKeys(new Set()); + setOpenTerminalSessionKey(""); + setTakeoverTerminalSessionKey(""); } }, [snapshot]); + useEffect(() => { + setOpenTerminalSessionKey(""); + setTakeoverTerminalSessionKey(""); + }, [selectedSessionKey]); const selectedExtensionDock = useMemo(() => buildExtensionDockModel(selectedExtensionUi), [selectedExtensionUi]); const displayedSessionTitle = selectedExtensionUi?.title ?? selectedSession?.title ?? ""; const activeExtensionDialog = selectedExtensionUi?.pendingDialogs[0]; @@ -422,21 +426,13 @@ export default function App() { if (!selectedSessionKey) { return; } - if (openTerminalSessionKeys.has(selectedSessionKey)) { - setOpenTerminalSessionKeys((current) => { - const next = new Set(current); - next.delete(selectedSessionKey); - return next; - }); - setTakeoverTerminalSessionKeys((current) => { - const next = new Set(current); - next.delete(selectedSessionKey); - return next; - }); + if (openTerminalSessionKey === selectedSessionKey) { + setOpenTerminalSessionKey(""); + setTakeoverTerminalSessionKey(""); return; } - setOpenTerminalSessionKeys((current) => new Set(current).add(selectedSessionKey)); - }, [openTerminalSessionKeys, selectedSessionKey]); + setOpenTerminalSessionKey(selectedSessionKey); + }, [openTerminalSessionKey, selectedSessionKey]); const focusNewThreadComposer = () => { window.requestAnimationFrame(() => { newThreadComposerRef.current?.focus(); @@ -1288,34 +1284,14 @@ export default function App() { isTakeover={isTerminalTakeoverForSelectedThread} onHeightChange={(nextHeight) => { setTerminalHeight(nextHeight); - setTakeoverTerminalSessionKeys((current) => { - const next = new Set(current); - next.delete(selectedSessionKey); - return next; - }); + setTakeoverTerminalSessionKey((current) => (current === selectedSessionKey ? "" : current)); }} onToggleTakeover={() => { - setTakeoverTerminalSessionKeys((current) => { - const next = new Set(current); - if (next.has(selectedSessionKey)) { - next.delete(selectedSessionKey); - } else { - next.add(selectedSessionKey); - } - return next; - }); + setTakeoverTerminalSessionKey((current) => (current === selectedSessionKey ? "" : selectedSessionKey)); }} onHide={() => { - setOpenTerminalSessionKeys((current) => { - const next = new Set(current); - next.delete(selectedSessionKey); - return next; - }); - setTakeoverTerminalSessionKeys((current) => { - const next = new Set(current); - next.delete(selectedSessionKey); - return next; - }); + setOpenTerminalSessionKey((current) => (current === selectedSessionKey ? "" : current)); + setTakeoverTerminalSessionKey((current) => (current === selectedSessionKey ? "" : current)); focusComposer(); }} /> @@ -1710,6 +1686,8 @@ export default function App() { }; const handleSelectSession = (target: { workspaceId: string; sessionId: string }) => { + setOpenTerminalSessionKey(""); + setTakeoverTerminalSessionKey(""); void updateSnapshot(api, setSnapshot, () => api.selectSession(target)).then(() => { focusComposer(); }); diff --git a/apps/desktop/tests/core/integrated-terminal.spec.ts b/apps/desktop/tests/core/integrated-terminal.spec.ts index 685f7c10..047f4a26 100644 --- a/apps/desktop/tests/core/integrated-terminal.spec.ts +++ b/apps/desktop/tests/core/integrated-terminal.spec.ts @@ -139,7 +139,10 @@ test("pastes clipboard text into the integrated terminal once", async () => { const terminal = window.getByTestId("integrated-terminal"); await expect(terminal).toBeVisible(); await terminal.locator(".xterm").click(); - await expect(terminal.locator(".xterm-rows")).toContainText(basename(workspacePath), { timeout: 15_000 }); + await expect(terminal.locator(".xterm-rows")).toContainText( + new RegExp(`${escapeRegExp(basename(workspacePath))}|[#$%]\\s*$`), + { timeout: 15_000 }, + ); await harness.electronApp.evaluate(({ clipboard }) => { clipboard.writeText("PI_TERMINAL_PASTE_ONCE"); @@ -163,3 +166,7 @@ function countOccurrences(value: string, needle: string): number { } return count; } + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +}