diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ef82fc57f..1920be4c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,6 +66,8 @@ pub fn run() { settings::update_app_settings, settings::get_codex_config_path, menu::menu_set_accelerators, + menu::app_quit, + menu::ack_menu_quit, codex::codex_doctor, workspaces::list_workspaces, workspaces::is_workspace_path_dir, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 770890c6e..4b173c5a0 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -1,9 +1,15 @@ use std::collections::HashMap; use std::sync::Mutex; +use std::sync::atomic::Ordering; use serde::Deserialize; use tauri::menu::{Menu, MenuItem, MenuItemBuilder, PredefinedMenuItem, Submenu}; -use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder}; +use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder}; +use tokio::time::{Duration, sleep}; + +use crate::state::AppState; + +const MENU_QUIT_FALLBACK_MS: u64 = 2000; pub struct MenuItemRegistry { items: Mutex>>, @@ -58,6 +64,17 @@ pub fn menu_set_accelerators( Ok(()) } +#[tauri::command] +pub fn app_quit(app: AppHandle) { + app.exit(0); +} + +#[tauri::command] +pub fn ack_menu_quit(app: AppHandle) { + let state = app.state::(); + state.menu_quit_ack.store(true, Ordering::SeqCst); +} + pub(crate) fn build_menu( handle: &tauri::AppHandle, ) -> tauri::Result> { @@ -70,6 +87,10 @@ pub(crate) fn build_menu( let settings_item = MenuItemBuilder::with_id("file_open_settings", "Settings...") .accelerator("CmdOrCtrl+,") .build(handle)?; + #[cfg(target_os = "macos")] + let app_quit_item = MenuItemBuilder::with_id("app_quit", format!("Quit {app_name}")) + .accelerator("CmdOrCtrl+Q") + .build(handle)?; let app_menu = Submenu::with_items( handle, app_name.clone(), @@ -84,6 +105,9 @@ pub(crate) fn build_menu( &PredefinedMenuItem::hide(handle, None)?, &PredefinedMenuItem::hide_others(handle, None)?, &PredefinedMenuItem::separator(handle)?, + #[cfg(target_os = "macos")] + &app_quit_item, + #[cfg(not(target_os = "macos"))] &PredefinedMenuItem::quit(handle, None)?, ], )?; @@ -359,6 +383,31 @@ pub(crate) fn handle_menu_event( "file_quit" => { app.exit(0); } + "app_quit" => { + if should_hold_to_quit(app) { + let state = app.state::(); + state.menu_quit_ack.store(false, Ordering::SeqCst); + if let Some(window) = app.get_webview_window("main") { + if window.emit("menu-quit", ()).is_err() { + app.exit(0); + return; + } + } else { + app.exit(0); + return; + } + let app_handle = app.clone(); + tauri::async_runtime::spawn(async move { + sleep(Duration::from_millis(MENU_QUIT_FALLBACK_MS)).await; + let state = app_handle.state::(); + if !state.menu_quit_ack.load(Ordering::SeqCst) { + app_handle.exit(0); + } + }); + } else { + app.exit(0); + } + } "view_fullscreen" => { if let Some(window) = app.get_webview_window("main") { let is_fullscreen = window.is_fullscreen().unwrap_or(false); @@ -400,3 +449,11 @@ fn emit_menu_event(app: &tauri::AppHandle, event: &str) { let _ = app.emit(event, ()); } } + +fn should_hold_to_quit(app: &tauri::AppHandle) -> bool { + let state = app.state::(); + tauri::async_runtime::block_on(async { + let settings = state.app_settings.lock().await; + settings.experimental_hold_to_quit_enabled + }) +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index eb82133d9..5ee051e34 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use tauri::{AppHandle, Manager}; use tokio::sync::Mutex; @@ -19,6 +20,7 @@ pub(crate) struct AppState { pub(crate) settings_path: PathBuf, pub(crate) app_settings: Mutex, pub(crate) dictation: Mutex, + pub(crate) menu_quit_ack: AtomicBool, } impl AppState { @@ -40,6 +42,7 @@ impl AppState { settings_path, app_settings: Mutex::new(app_settings), dictation: Mutex::new(DictationState::default()), + menu_quit_ack: AtomicBool::new(false), } } } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 88d1e8a6b..d3252b656 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -402,6 +402,11 @@ pub(crate) struct AppSettings { rename = "experimentalUnifiedExecEnabled" )] pub(crate) experimental_unified_exec_enabled: bool, + #[serde( + default = "default_experimental_hold_to_quit_enabled", + rename = "experimentalHoldToQuitEnabled" + )] + pub(crate) experimental_hold_to_quit_enabled: bool, #[serde(default = "default_dictation_enabled", rename = "dictationEnabled")] pub(crate) dictation_enabled: bool, #[serde( @@ -567,6 +572,10 @@ fn default_experimental_unified_exec_enabled() -> bool { false } +fn default_experimental_hold_to_quit_enabled() -> bool { + false +} + fn default_dictation_enabled() -> bool { false } @@ -711,6 +720,7 @@ impl Default for AppSettings { experimental_collaboration_modes_enabled: false, experimental_steer_enabled: false, experimental_unified_exec_enabled: false, + experimental_hold_to_quit_enabled: false, dictation_enabled: false, dictation_model_id: default_dictation_model_id(), dictation_preferred_language: None, @@ -794,6 +804,7 @@ mod tests { assert_eq!(settings.code_font_size, 11); assert!(settings.notification_sounds_enabled); assert!(!settings.experimental_steer_enabled); + assert!(!settings.experimental_hold_to_quit_enabled); assert!(!settings.dictation_enabled); assert_eq!(settings.dictation_model_id, "base"); assert!(settings.dictation_preferred_language.is_none()); diff --git a/src/App.tsx b/src/App.tsx index b7ec61fcb..02919bec8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import "./styles/messages.css"; import "./styles/approval-toasts.css"; import "./styles/request-user-input.css"; import "./styles/update-toasts.css"; +import "./styles/quit-hold.css"; import "./styles/composer.css"; import "./styles/diff.css"; import "./styles/diff-viewer.css"; @@ -31,6 +32,7 @@ import errorSoundUrl from "./assets/error-notification.mp3"; import { AppLayout } from "./features/app/components/AppLayout"; import { AppModals } from "./features/app/components/AppModals"; import { MainHeaderActions } from "./features/app/components/MainHeaderActions"; +import { QuitHoldIndicator } from "./features/app/components/QuitHoldIndicator"; import { useLayoutNodes } from "./features/layout/hooks/useLayoutNodes"; import { useWorkspaceDropZone } from "./features/workspaces/hooks/useWorkspaceDropZone"; import { useThreads } from "./features/threads/hooks/useThreads"; @@ -78,6 +80,7 @@ import { useSettingsModalState } from "./features/app/hooks/useSettingsModalStat import { usePersistComposerSettings } from "./features/app/hooks/usePersistComposerSettings"; import { useSyncSelectedDiffPath } from "./features/app/hooks/useSyncSelectedDiffPath"; import { useMenuAcceleratorController } from "./features/app/hooks/useMenuAcceleratorController"; +import { useHoldToQuit } from "./features/app/hooks/useHoldToQuit"; import { useAppMenuEvents } from "./features/app/hooks/useAppMenuEvents"; import { useWorkspaceActions } from "./features/app/hooks/useWorkspaceActions"; import { useWorkspaceCycling } from "./features/app/hooks/useWorkspaceCycling"; @@ -1379,6 +1382,9 @@ function MainApp() { }); useMenuAcceleratorController({ appSettings, onDebug: addDebugEntry }); + const { state: quitHoldState } = useHoldToQuit({ + enabled: appSettings.experimentalHoldToQuitEnabled, + }); const isDefaultScale = Math.abs(uiScale - 1) < 0.001; const dropOverlayActive = isWorkspaceDropActive; @@ -1852,6 +1858,10 @@ function MainApp() { /> ) : null} + + + + + + ); +} diff --git a/src/features/app/components/QuitHoldToast.tsx b/src/features/app/components/QuitHoldToast.tsx new file mode 100644 index 000000000..c29978241 --- /dev/null +++ b/src/features/app/components/QuitHoldToast.tsx @@ -0,0 +1,36 @@ +import type { QuitHoldState } from "../hooks/useHoldToQuit"; + +type QuitHoldToastProps = { + state: QuitHoldState; +}; + +export function QuitHoldToast({ state }: QuitHoldToastProps) { + if (!state.isVisible) { + return null; + } + + const isCanceled = state.status === "canceled"; + const message = isCanceled ? "Quit canceled" : "Hold Cmd+Q to quit"; + const progressPercent = Math.round(state.progress * 100); + + return ( +
+
+
{message}
+ {!isCanceled && ( +
+
+ +
+
+ )} +
+
+ ); +} diff --git a/src/features/app/hooks/useHoldToQuit.test.tsx b/src/features/app/hooks/useHoldToQuit.test.tsx new file mode 100644 index 000000000..998927fae --- /dev/null +++ b/src/features/app/hooks/useHoldToQuit.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { requestAppQuit } from "../../../services/tauri"; +import { useHoldToQuit } from "./useHoldToQuit"; + +vi.mock("../../../services/tauri", () => ({ + ackMenuQuit: vi.fn(), + requestAppQuit: vi.fn(), +})); +vi.mock("../../../services/events", () => ({ + subscribeMenuQuit: () => () => {}, +})); + +describe("useHoldToQuit", () => { + const originalPlatform = navigator.platform; + + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, "platform", { + value: "MacIntel", + configurable: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(navigator, "platform", { + value: originalPlatform, + configurable: true, + }); + vi.clearAllMocks(); + }); + + it("cancels hold and hides toast on key release", () => { + const { result } = renderHook(() => useHoldToQuit({ enabled: true })); + + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "q", metaKey: true }), + ); + }); + + expect(result.current.state.status).toBe("holding"); + expect(result.current.state.isVisible).toBe(true); + + act(() => { + window.dispatchEvent(new KeyboardEvent("keyup", { key: "q" })); + }); + + expect(result.current.state.status).toBe("canceled"); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(result.current.state.isVisible).toBe(false); + }); + + it("requests quit after holding long enough", async () => { + const requestMock = vi.mocked(requestAppQuit); + requestMock.mockResolvedValue(); + + renderHook(() => useHoldToQuit({ enabled: true })); + + act(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "q", metaKey: true }), + ); + }); + + act(() => { + vi.advanceTimersByTime(1600); + }); + + expect(requestMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/app/hooks/useHoldToQuit.ts b/src/features/app/hooks/useHoldToQuit.ts new file mode 100644 index 000000000..9f8186b4d --- /dev/null +++ b/src/features/app/hooks/useHoldToQuit.ts @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { subscribeMenuQuit } from "../../../services/events"; +import { ackMenuQuit, requestAppQuit } from "../../../services/tauri"; +import { useTauriEvent } from "./useTauriEvent"; + +export type QuitHoldState = { + isVisible: boolean; + progress: number; + status: "idle" | "holding" | "canceled"; +}; + +type UseHoldToQuitOptions = { + enabled: boolean; +}; + +const HOLD_DURATION_MS = 1500; +const HOLD_TICK_MS = 50; +const CANCEL_HIDE_DELAY_MS = 900; +const MENU_WINDOW_MS = 500; +let quitInProgress = false; + +const defaultState: QuitHoldState = { + isVisible: false, + progress: 0, + status: "idle", +}; + +function detectMacPlatform() { + const platform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? navigator.platform ?? ""; + return /mac|iphone|ipad|ipod/i.test(platform); +} + +export function useHoldToQuit({ enabled }: UseHoldToQuitOptions) { + const [state, setState] = useState(defaultState); + const isMac = useMemo(() => detectMacPlatform(), []); + const holdStartedAtRef = useRef(null); + const holdIntervalRef = useRef(null); + const hideTimeoutRef = useRef(null); + const quitRequestedRef = useRef(false); + const lastCmdQRef = useRef(null); + const isHoldingRef = useRef(false); + + useEffect(() => { + isHoldingRef.current = state.status === "holding"; + }, [state.status]); + + const clearHoldInterval = useCallback(() => { + if (holdIntervalRef.current !== null) { + window.clearInterval(holdIntervalRef.current); + holdIntervalRef.current = null; + } + }, []); + + const clearHideTimeout = useCallback(() => { + if (hideTimeoutRef.current !== null) { + window.clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }, []); + + const scheduleHide = useCallback(() => { + clearHideTimeout(); + hideTimeoutRef.current = window.setTimeout(() => { + setState(defaultState); + }, CANCEL_HIDE_DELAY_MS); + }, [clearHideTimeout]); + + const cancelHold = useCallback(() => { + if (holdStartedAtRef.current === null) { + return; + } + holdStartedAtRef.current = null; + clearHoldInterval(); + setState({ isVisible: true, progress: 0, status: "canceled" }); + scheduleHide(); + }, [clearHoldInterval, scheduleHide]); + + const startHold = useCallback(() => { + if (holdStartedAtRef.current !== null || quitInProgress) { + return; + } + clearHideTimeout(); + holdStartedAtRef.current = Date.now(); + quitRequestedRef.current = false; + setState({ isVisible: true, progress: 0, status: "holding" }); + clearHoldInterval(); + holdIntervalRef.current = window.setInterval(() => { + if (holdStartedAtRef.current === null) { + return; + } + const elapsedMs = Date.now() - holdStartedAtRef.current; + const progress = Math.min(1, elapsedMs / HOLD_DURATION_MS); + setState({ isVisible: true, progress, status: "holding" }); + if (progress >= 1 && !quitRequestedRef.current && !quitInProgress) { + quitRequestedRef.current = true; + quitInProgress = true; + holdStartedAtRef.current = null; + clearHoldInterval(); + void requestAppQuit() + .catch(() => { + setState({ isVisible: true, progress: 0, status: "canceled" }); + scheduleHide(); + }) + .finally(() => { + quitInProgress = false; + }); + } + }, HOLD_TICK_MS); + }, [clearHideTimeout, clearHoldInterval, scheduleHide]); + + useEffect(() => { + if (!isMac || !enabled) { + holdStartedAtRef.current = null; + clearHoldInterval(); + clearHideTimeout(); + setState(defaultState); + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.metaKey || event.key.toLowerCase() !== "q") { + return; + } + event.preventDefault(); + lastCmdQRef.current = Date.now(); + if (!event.repeat) { + startHold(); + } + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (holdStartedAtRef.current === null) { + return; + } + const key = event.key.toLowerCase(); + if (key === "q" || event.key === "Meta") { + cancelHold(); + } + }; + + const handleBlur = () => { + cancelHold(); + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + window.addEventListener("keyup", handleKeyUp, { capture: true }); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("keyup", handleKeyUp, true); + window.removeEventListener("blur", handleBlur); + clearHoldInterval(); + clearHideTimeout(); + }; + }, [cancelHold, clearHideTimeout, clearHoldInterval, enabled, isMac, startHold]); + + useTauriEvent( + subscribeMenuQuit, + () => { + void ackMenuQuit(); + if (!enabled || !isMac) { + void requestAppQuit(); + return; + } + if (isHoldingRef.current) { + return; + } + const lastCmdQ = lastCmdQRef.current; + if (lastCmdQ && Date.now() - lastCmdQ <= MENU_WINDOW_MS) { + startHold(); + } else { + void requestAppQuit(); + } + }, + { enabled: enabled && isMac }, + ); + + return { state }; +} diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index bc01bfa2b..c5347d0b7 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -52,6 +52,7 @@ const baseSettings: AppSettings = { experimentalCollaborationModesEnabled: false, experimentalSteerEnabled: false, experimentalUnifiedExecEnabled: false, + experimentalHoldToQuitEnabled: false, dictationEnabled: false, dictationModelId: "base", dictationPreferredLanguage: null, @@ -162,7 +163,13 @@ describe("SettingsView Display", () => { if (!row) { throw new Error("Expected reduce transparency row"); } - fireEvent.click(within(row).getByRole("button")); + const toggle = row.querySelector( + "button.settings-toggle", + ) as HTMLButtonElement | null; + if (!toggle) { + throw new Error("Expected hold-to-quit toggle"); + } + fireEvent.click(toggle); expect(onToggleTransparency).toHaveBeenCalledWith(true); }); @@ -278,6 +285,67 @@ describe("SettingsView Display", () => { }); }); +describe("SettingsView Experimental", () => { + it("toggles hold-to-quit in experimental settings", async () => { + cleanup(); + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + const props: ComponentProps = { + reduceTransparency: false, + onToggleTransparency: vi.fn(), + appSettings: baseSettings, + onUpdateAppSettings, + workspaceGroups: [], + groupedWorkspaces: [], + ungroupedLabel: "Ungrouped", + onClose: vi.fn(), + onMoveWorkspace: vi.fn(), + onDeleteWorkspace: vi.fn(), + onCreateWorkspaceGroup: vi.fn().mockResolvedValue(null), + onRenameWorkspaceGroup: vi.fn().mockResolvedValue(null), + onMoveWorkspaceGroup: vi.fn().mockResolvedValue(null), + onDeleteWorkspaceGroup: vi.fn().mockResolvedValue(null), + onAssignWorkspaceGroup: vi.fn().mockResolvedValue(null), + onRunDoctor: vi.fn().mockResolvedValue(createDoctorResult()), + onUpdateWorkspaceCodexBin: vi.fn().mockResolvedValue(undefined), + scaleShortcutTitle: "Scale shortcut", + scaleShortcutText: "Use Command +/-", + onTestNotificationSound: vi.fn(), + dictationModelStatus: null, + onDownloadDictationModel: vi.fn(), + onCancelDictationDownload: vi.fn(), + onRemoveDictationModel: vi.fn(), + }; + + render(); + const sidebar = document.querySelector( + ".settings-sidebar", + ) as HTMLElement | null; + if (!sidebar) { + throw new Error("Expected settings sidebar"); + } + const experimentalButton = within(sidebar).getByRole("button", { + name: "Experimental", + }); + fireEvent.click(experimentalButton); + + const row = screen + .getByText("Hold ⌘Q to quit") + .closest(".settings-toggle-row") as HTMLElement | null; + if (!row) { + throw new Error("Expected hold-to-quit row"); + } + fireEvent.click(within(row).getByRole("button")); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ + experimentalHoldToQuitEnabled: true, + }), + ); + }); + }); +}); + describe("SettingsView Shortcuts", () => { it("closes on Cmd+W", () => { const onClose = vi.fn(); diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index ea2b9d06e..6950ac9e6 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -2597,6 +2597,30 @@ export function SettingsView({ {openConfigError && (
{openConfigError}
)} +
+
+
Hold ⌘Q to quit
+
+ Show a hold-to-quit progress bar at the top of the window. +
+
+ +
Multi-agent
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index aaf5865ac..55dece14a 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -51,6 +51,7 @@ const defaultSettings: AppSettings = { experimentalCollaborationModesEnabled: false, experimentalSteerEnabled: false, experimentalUnifiedExecEnabled: false, + experimentalHoldToQuitEnabled: false, dictationEnabled: false, dictationModelId: "base", dictationPreferredLanguage: null, diff --git a/src/services/events.ts b/src/services/events.ts index 87935018d..bd1ae561d 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -90,6 +90,7 @@ const menuToggleProjectsSidebarHub = createEventHub("menu-toggle-projects- const menuToggleGitSidebarHub = createEventHub("menu-toggle-git-sidebar"); const menuToggleDebugPanelHub = createEventHub("menu-toggle-debug-panel"); const menuToggleTerminalHub = createEventHub("menu-toggle-terminal"); +const menuQuitHub = createEventHub("menu-quit"); const menuNextAgentHub = createEventHub("menu-next-agent"); const menuPrevAgentHub = createEventHub("menu-prev-agent"); const menuNextWorkspaceHub = createEventHub("menu-next-workspace"); @@ -223,6 +224,15 @@ export function subscribeMenuToggleTerminal( }, options); } +export function subscribeMenuQuit( + onEvent: () => void, + options?: SubscriptionOptions, +): Unsubscribe { + return menuQuitHub.subscribe(() => { + onEvent(); + }, options); +} + export function subscribeMenuNextAgent( onEvent: () => void, options?: SubscriptionOptions, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index a29f9836e..bbb82e570 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -53,6 +53,14 @@ export async function getCodexConfigPath(): Promise { return invoke("get_codex_config_path"); } +export async function requestAppQuit(): Promise { + return invoke("app_quit"); +} + +export async function ackMenuQuit(): Promise { + return invoke("ack_menu_quit"); +} + export async function addWorkspace( path: string, codex_bin: string | null, diff --git a/src/styles/quit-hold-toast.css b/src/styles/quit-hold-toast.css new file mode 100644 index 000000000..e95556072 --- /dev/null +++ b/src/styles/quit-hold-toast.css @@ -0,0 +1,69 @@ +.quit-hold-toasts { + position: absolute; + bottom: 96px; + right: 20px; + width: min(320px, calc(100vw - 40px)); + display: grid; + gap: 12px; + z-index: 6; + pointer-events: none; + -webkit-app-region: no-drag; +} + +.quit-hold-toast { + background: var(--surface-context-core); + border-radius: 12px; + border: 1px solid var(--border-subtle); + padding: 12px; + box-shadow: 0 16px 32px rgba(0, 0, 0, 0.25); + pointer-events: auto; + animation: quit-hold-toast-in 0.2s ease-out; + max-width: 100%; +} + +.quit-hold-toast-body { + font-size: 13px; + color: var(--text); +} + +.quit-hold-toast.canceled .quit-hold-toast-body { + color: var(--text-muted); +} + +.quit-hold-toast-progress { + margin-top: 8px; +} + +.quit-hold-toast-progress-bar { + height: 6px; + border-radius: 999px; + background: var(--surface-card-muted); + overflow: hidden; +} + +.quit-hold-toast-progress-fill { + display: block; + height: 100%; + background: linear-gradient(90deg, #f59e0b, #ef4444); + transition: width 0.05s linear; +} + +@keyframes quit-hold-toast-in { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 960px) { + .quit-hold-toasts { + bottom: 120px; + left: 50%; + right: auto; + transform: translateX(-50%); + } +} diff --git a/src/styles/quit-hold.css b/src/styles/quit-hold.css new file mode 100644 index 000000000..278547d89 --- /dev/null +++ b/src/styles/quit-hold.css @@ -0,0 +1,27 @@ +.quit-hold-indicator { + position: absolute; + top: calc(var(--titlebar-height, 24px) + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 6; + width: min(260px, 60vw); + pointer-events: none; +} + +.quit-hold-track { + display: block; + height: 6px; + border-radius: 999px; + background: color-mix(in srgb, var(--border-accent-soft) 55%, transparent); + border: 1px solid var(--border-accent-soft); + overflow: hidden; + box-shadow: 0 10px 22px var(--shadow-accent); +} + +.quit-hold-fill { + display: block; + height: 100%; + width: 0; + background: var(--border-accent); + transition: width 60ms linear; +} diff --git a/src/types.ts b/src/types.ts index 34954166f..6dccf4630 100644 --- a/src/types.ts +++ b/src/types.ts @@ -131,6 +131,7 @@ export type AppSettings = { experimentalCollaborationModesEnabled: boolean; experimentalSteerEnabled: boolean; experimentalUnifiedExecEnabled: boolean; + experimentalHoldToQuitEnabled: boolean; dictationEnabled: boolean; dictationModelId: string; dictationPreferredLanguage: string | null;