diff --git a/RELEASING.linux.md b/RELEASING.linux.md new file mode 100644 index 000000000..dbe24e70b --- /dev/null +++ b/RELEASING.linux.md @@ -0,0 +1,71 @@ +# Releasing CodexMonitor (Linux/AppImage) + +This is a copy/paste friendly script-style guide. It must be run on Linux and +produces an AppImage for the current machine architecture (x86_64 or +arm64/aarch64). + +Prerequisites (examples): + +- Ubuntu/Debian: `sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf libfuse2` +- Arch: `sudo pacman -S --needed webkit2gtk gtk3 libayatana-appindicator librsvg patchelf fuse2` + +Notes: + +- AppImage bundling uses the current OS and arch (no cross-compile in this flow). +- On Arch, AppImage bundling can fail while stripping; `npm run build:appimage` + already sets `NO_STRIP=1`. +- If AppImage tooling fails to execute because of FUSE, try: + `APPIMAGE_EXTRACT_AND_RUN=1 npm run build:appimage`. + +```bash +set -euo pipefail + +# --- Set versions --- +# Update these two values each release. +RELEASE_VERSION="0.5.1" +PREV_VERSION="0.5.0" + +# --- Update version fields (manual check afterwards) --- +perl -pi -e "s/\"version\": \"[^\"]+\"/\"version\": \"${RELEASE_VERSION}\"/" package.json +perl -pi -e "s/\"version\": \"[^\"]+\"/\"version\": \"${RELEASE_VERSION}\"/" src-tauri/tauri.conf.json +npm install + +# --- Commit + push version bump --- +git add package.json package-lock.json src-tauri/tauri.conf.json +git commit -m "chore: bump version to ${RELEASE_VERSION}" +git push origin main + +# --- Build AppImage --- +npm run build:appimage + +# --- Collect artifact --- +ARCH="$(uname -m)" +APPIMAGE_DIR="src-tauri/target/release/bundle/appimage" +APPIMAGE_FILE="$(ls "${APPIMAGE_DIR}"/*.AppImage | head -n 1)" +mkdir -p release-artifacts +cp "${APPIMAGE_FILE}" "release-artifacts/CodexMonitor_${RELEASE_VERSION}_${ARCH}.AppImage" + +# Optional: checksum +sha256sum "release-artifacts/CodexMonitor_${RELEASE_VERSION}_${ARCH}.AppImage" \ + > "release-artifacts/CodexMonitor_${RELEASE_VERSION}_${ARCH}.AppImage.sha256" + +# --- Changelog (for release notes) --- +git log --name-only --pretty=format:"%h %s" v${PREV_VERSION}..v${RELEASE_VERSION} + +# --- Tag --- +git tag v${RELEASE_VERSION} +git push origin v${RELEASE_VERSION} + +# --- Create GitHub release with artifact --- +gh release create v${RELEASE_VERSION} \ + --title "v${RELEASE_VERSION}" \ + --notes "- Short update notes" \ + "release-artifacts/CodexMonitor_${RELEASE_VERSION}_${ARCH}.AppImage" \ + "release-artifacts/CodexMonitor_${RELEASE_VERSION}_${ARCH}.AppImage.sha256" + +# --- If you need to update assets later --- +gh release upload v${RELEASE_VERSION} \ + "release-artifacts/CodexMonitor_${RELEASE_VERSION}_${ARCH}.AppImage" \ + "release-artifacts/CodexMonitor_${RELEASE_VERSION}_${ARCH}.AppImage.sha256" \ + --clobber +``` diff --git a/package.json b/package.json index e097ed3cf..2ec5803c4 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "build:appimage": "NO_STRIP=1 tauri build --bundles appimage", "typecheck": "tsc --noEmit", "preview": "vite preview", "tauri": "tauri" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 41393231f..8b0ceab30 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -462,6 +462,7 @@ dependencies = [ name = "codex-monitor" version = "0.1.0" dependencies = [ + "fix-path-env", "git2", "serde", "serde_json", @@ -956,6 +957,16 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +[[package]] +name = "fix-path-env" +version = "0.0.0" +source = "git+https://github.com/tauri-apps/fix-path-env-rs#c4c45d503ea115a839aae718d02f79e7c7f0f673" +dependencies = [ + "home", + "strip-ansi-escapes", + "thiserror 1.0.69", +] + [[package]] name = "flate2" version = "1.1.5" @@ -1458,6 +1469,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -3705,6 +3725,15 @@ dependencies = [ "quote", ] +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4714,6 +4743,15 @@ dependencies = [ "libc", ] +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f1caeb424..ff9a618e7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ tokio = { version = "1", features = ["io-util", "process", "rt", "sync", "time"] uuid = { version = "1", features = ["v4"] } tauri-plugin-dialog = "2" git2 = "0.20.3" +fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } [target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies] tauri-plugin-updater = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b2770ffea..4d34086e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,14 @@ mod workspaces; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + #[cfg(target_os = "linux")] + { + // Avoid WebKit compositing issues on some Linux setups (GBM buffer errors). + if std::env::var_os("WEBKIT_DISABLE_COMPOSITING_MODE").is_none() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } + } + tauri::Builder::default() .enable_macos_default_menu(false) .menu(|handle| { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 77d642a2e..0b817d97c 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if let Err(err) = fix_path_env::fix() { + eprintln!("Failed to sync PATH from shell: {err}"); + } codex_monitor_lib::run() } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index e0c7b6a10..acf3064be 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -130,17 +130,24 @@ pub(crate) struct AppSettings { pub(crate) codex_bin: Option, #[serde(default = "default_access_mode", rename = "defaultAccessMode")] pub(crate) default_access_mode: String, + #[serde(default = "default_ui_scale", rename = "uiScale")] + pub(crate) ui_scale: f64, } fn default_access_mode() -> String { "current".to_string() } +fn default_ui_scale() -> f64 { + 1.0 +} + impl Default for AppSettings { fn default() -> Self { Self { codex_bin: None, default_access_mode: "current".to_string(), + ui_scale: 1.0, } } } @@ -154,6 +161,7 @@ mod tests { let settings: AppSettings = serde_json::from_str("{}").expect("settings deserialize"); assert!(settings.codex_bin.is_none()); assert_eq!(settings.default_access_mode, "current"); + assert!((settings.ui_scale - 1.0).abs() < f64::EPSILON); } #[test] diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 000000000..dd6b55f0c --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,18 @@ +{ + "app": { + "windows": [ + { + "title": "CodexMonitor", + "width": 1200, + "height": 700, + "minWidth": 360, + "minHeight": 600, + "titleBarStyle": "Visible", + "hiddenTitle": false, + "transparent": false, + "devtools": true, + "windowEffects": null + } + ] + } +} diff --git a/src/App.tsx b/src/App.tsx index 9e1776a8e..7315d7df6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -57,12 +57,14 @@ import { useResizablePanels } from "./hooks/useResizablePanels"; import { useLayoutMode } from "./hooks/useLayoutMode"; import { useAppSettings } from "./hooks/useAppSettings"; import { useUpdater } from "./hooks/useUpdater"; +import { clampUiScale, UI_SCALE_STEP } from "./utils/uiScale"; import { useComposerImages } from "./hooks/useComposerImages"; import type { AccessMode, + AppSettings, DiffLineReference, QueuedMessage, - WorkspaceInfo, + WorkspaceInfo } from "./types"; function useWindowLabel() { @@ -79,6 +81,13 @@ function useWindowLabel() { } function MainApp() { + const { + settings: appSettings, + setSettings: setAppSettings, + saveSettings, + doctor + } = useAppSettings(); + const uiScale = clampUiScale(appSettings.uiScale); const { sidebarWidth, rightPanelWidth, @@ -87,17 +96,17 @@ function MainApp() { planPanelHeight, onPlanPanelResizeStart, debugPanelHeight, - onDebugPanelResizeStart, - } = useResizablePanels(); + onDebugPanelResizeStart + } = useResizablePanels(uiScale); const layoutMode = useLayoutMode(); const isCompact = layoutMode !== "desktop"; const isTablet = layoutMode === "tablet"; const isPhone = layoutMode === "phone"; const [centerMode, setCenterMode] = useState<"chat" | "diff">("chat"); const [selectedDiffPath, setSelectedDiffPath] = useState(null); - const [gitPanelMode, setGitPanelMode] = useState< - "diff" | "log" | "issues" - >("diff"); + const [gitPanelMode, setGitPanelMode] = useState<"diff" | "log" | "issues">( + "diff" + ); const [accessMode, setAccessMode] = useState("current"); const [activeTab, setActiveTab] = useState< "projects" | "codex" | "git" | "log" @@ -110,15 +119,17 @@ function MainApp() { Record >({}); const [prefillDraft, setPrefillDraft] = useState(null); - const [composerInsert, setComposerInsert] = useState(null); + const [composerInsert, setComposerInsert] = useState( + null + ); const [settingsOpen, setSettingsOpen] = useState(false); const [reduceTransparency, setReduceTransparency] = useState(() => { const stored = localStorage.getItem("reduceTransparency"); return stored === "true"; }); - const [flushingByThread, setFlushingByThread] = useState>( - {}, - ); + const [flushingByThread, setFlushingByThread] = useState< + Record + >({}); const [worktreePrompt, setWorktreePrompt] = useState<{ workspace: WorkspaceInfo; branch: string; @@ -132,14 +143,35 @@ function MainApp() { hasDebugAlerts, addDebugEntry, handleCopyDebug, - clearDebugEntries, + clearDebugEntries } = useDebugLog(); const composerInputRef = useRef(null); const updater = useUpdater({ onDebug: addDebugEntry }); - const { settings: appSettings, saveSettings, doctor } = useAppSettings(); + const scaleShortcutLabel = (() => { + if (typeof navigator === "undefined") { + return "Ctrl"; + } + return /Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Cmd" : "Ctrl"; + })(); + const scaleShortcutTitle = `${scaleShortcutLabel}+ and ${scaleShortcutLabel}-, ${scaleShortcutLabel}+0 to reset.`; + const scaleShortcutText = `Shortcuts: ${scaleShortcutLabel}+ and ${scaleShortcutLabel}-, ${scaleShortcutLabel}+0 to reset.`; + + const saveQueueRef = useRef(Promise.resolve()); + const queueSaveSettings = useCallback( + (next: AppSettings) => { + const task = () => saveSettings(next); + const queued = saveQueueRef.current.then(task, task); + saveQueueRef.current = queued.then( + () => undefined, + () => undefined + ); + return queued; + }, + [saveSettings] + ); const { workspaces, @@ -155,12 +187,15 @@ function MainApp() { removeWorkspace, removeWorktree, hasLoaded, - refreshWorkspaces, - } = useWorkspaces({ onDebug: addDebugEntry, defaultCodexBin: appSettings.codexBin }); + refreshWorkspaces + } = useWorkspaces({ + onDebug: addDebugEntry, + defaultCodexBin: appSettings.codexBin + }); useEffect(() => { setAccessMode((prev) => - prev === "current" ? appSettings.defaultAccessMode : prev, + prev === "current" ? appSettings.defaultAccessMode : prev ); }, [appSettings.defaultAccessMode]); @@ -168,6 +203,65 @@ function MainApp() { localStorage.setItem("reduceTransparency", String(reduceTransparency)); }, [reduceTransparency]); + const handleScaleDelta = useCallback( + (delta: number) => { + setAppSettings((current) => { + const nextScale = clampUiScale(current.uiScale + delta); + if (nextScale === current.uiScale) { + return current; + } + const nextSettings = { + ...current, + uiScale: nextScale + }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + }, + [queueSaveSettings, setAppSettings] + ); + + const handleScaleReset = useCallback(() => { + setAppSettings((current) => { + if (current.uiScale === 1) { + return current; + } + const nextSettings = { + ...current, + uiScale: 1 + }; + void queueSaveSettings(nextSettings); + return nextSettings; + }); + }, [queueSaveSettings, setAppSettings]); + + useEffect(() => { + const handleScaleShortcut = (event: KeyboardEvent) => { + if (!event.metaKey && !event.ctrlKey) { + return; + } + if (event.altKey) { + return; + } + const key = event.key; + const isIncrease = key === "+" || key === "="; + const isDecrease = key === "-" || key === "_"; + const isReset = key === "0"; + if (!isIncrease && !isDecrease && !isReset) { + return; + } + event.preventDefault(); + if (isReset) { + handleScaleReset(); + return; + } + handleScaleDelta(isDecrease ? -UI_SCALE_STEP : UI_SCALE_STEP); + }; + window.addEventListener("keydown", handleScaleShortcut); + return () => { + window.removeEventListener("keydown", handleScaleShortcut); + }; + }, [handleScaleDelta, handleScaleReset]); const { status: gitStatus, refresh: refreshGitStatus } = useGitStatus(activeWorkspace); @@ -178,7 +272,7 @@ function MainApp() { const { diffs: gitDiffs, isLoading: isDiffLoading, - error: diffError, + error: diffError } = useGitDiffs(activeWorkspace, gitStatus.files, shouldLoadDiffs); const { entries: gitLogEntries, @@ -189,13 +283,13 @@ function MainApp() { behindEntries: gitLogBehindEntries, upstream: gitLogUpstream, isLoading: gitLogLoading, - error: gitLogError, + error: gitLogError } = useGitLog(activeWorkspace, shouldLoadGitLog); const { issues: gitIssues, total: gitIssuesTotal, isLoading: gitIssuesLoading, - error: gitIssuesError, + error: gitIssuesError } = useGitHubIssues(activeWorkspace, gitPanelMode === "issues"); const { remote: gitRemoteUrl } = useGitRemote(activeWorkspace); const { @@ -205,16 +299,15 @@ function MainApp() { setSelectedModelId, reasoningOptions, selectedEffort, - setSelectedEffort, + setSelectedEffort } = useModels({ activeWorkspace, onDebug: addDebugEntry }); const { skills } = useSkills({ activeWorkspace, onDebug: addDebugEntry }); const { prompts } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); const { files } = useWorkspaceFiles({ activeWorkspace, onDebug: addDebugEntry }); - const { - branches, - checkoutBranch, - createBranch, - } = useGitBranches({ activeWorkspace, onDebug: addDebugEntry }); + const { branches, checkoutBranch, createBranch } = useGitBranches({ + activeWorkspace, + onDebug: addDebugEntry + }); const handleCheckoutBranch = async (name: string) => { await checkoutBranch(name); refreshGitStatus(); @@ -227,7 +320,9 @@ function MainApp() { const resolvedModel = selectedModel?.model ?? null; const fileStatus = gitStatus.files.length > 0 - ? `${gitStatus.files.length} file${gitStatus.files.length === 1 ? "" : "s"} changed` + ? `${gitStatus.files.length} file${ + gitStatus.files.length === 1 ? "" : "s" + } changed` : "Working tree clean"; const { @@ -249,7 +344,7 @@ function MainApp() { listThreadsForWorkspace, sendUserMessage, startReview, - handleApprovalDecision, + handleApprovalDecision } = useThreads({ activeWorkspace, onWorkspaceConnected: markWorkspaceConnected, @@ -258,7 +353,7 @@ function MainApp() { effort: selectedEffort, accessMode, customPrompts: prompts, - onMessageActivity: refreshGitStatus, + onMessageActivity: refreshGitStatus }); const { activeImages, @@ -292,7 +387,7 @@ function MainApp() { timestamp: entry.timestamp, projectName: workspace.name, workspaceId: workspace.id, - isProcessing: threadStatusById[thread.id]?.isProcessing ?? false, + isProcessing: threadStatusById[thread.id]?.isProcessing ?? false }); }); }); @@ -301,15 +396,15 @@ function MainApp() { lastAgentMessageByThread, threadStatusById, threadsByWorkspace, - workspaces, + workspaces ]); const isLoadingLatestAgents = useMemo( () => !hasLoaded || workspaces.some( - (workspace) => threadListLoadingByWorkspace[workspace.id] ?? false, + (workspace) => threadListLoadingByWorkspace[workspace.id] ?? false ), - [hasLoaded, threadListLoadingByWorkspace, workspaces], + [hasLoaded, threadListLoadingByWorkspace, workspaces] ); const activeRateLimits = activeWorkspaceId @@ -318,15 +413,17 @@ function MainApp() { const activeTokenUsage = activeThreadId ? tokenUsageByThread[activeThreadId] ?? null : null; - const activePlan = activeThreadId ? planByThread[activeThreadId] ?? null : null; + const activePlan = activeThreadId + ? planByThread[activeThreadId] ?? null + : null; const hasActivePlan = Boolean( - activePlan && (activePlan.steps.length > 0 || activePlan.explanation), + activePlan && (activePlan.steps.length > 0 || activePlan.explanation) ); const showHome = !activeWorkspace; const canInterrupt = activeThreadId ? Boolean( threadStatusById[activeThreadId]?.isProcessing && - activeTurnIdByThread[activeThreadId], + activeTurnIdByThread[activeThreadId] ) : false; const isProcessing = activeThreadId @@ -348,10 +445,10 @@ function MainApp() { } setComposerDraftsByThread((prev) => ({ ...prev, - [activeThreadId]: next, + [activeThreadId]: next })); }, - [activeThreadId], + [activeThreadId] ); const isWorktreeWorkspace = activeWorkspace?.kind === "worktree"; const activeParentWorkspace = isWorktreeWorkspace @@ -384,12 +481,12 @@ function MainApp() { workspaces, hasLoaded, connectWorkspace, - listThreadsForWorkspace, + listThreadsForWorkspace }); useWorkspaceRefreshOnFocus({ workspaces, refreshWorkspaces, - listThreadsForWorkspace, + listThreadsForWorkspace }); // Cmd+N shortcut to create new agent in active workspace @@ -424,7 +521,7 @@ function MainApp() { timestamp: Date.now(), source: "error", label: "workspace/add error", - payload: message, + payload: message }); alert(`Failed to add workspace.\n\n${message}`); } @@ -435,7 +532,7 @@ function MainApp() { if (target?.settings.sidebarCollapsed) { void updateWorkspaceSettings(workspaceId, { ...target.settings, - sidebarCollapsed: false, + sidebarCollapsed: false }); } setActiveWorkspaceId(workspaceId); @@ -463,16 +560,18 @@ function MainApp() { setTimeout(() => composerInputRef.current?.focus(), 0); } - async function handleAddWorktreeAgent(workspace: (typeof workspaces)[number]) { + async function handleAddWorktreeAgent( + workspace: (typeof workspaces)[number] + ) { exitDiffView(); - const defaultBranch = `codex/${new Date().toISOString().slice(0, 10)}-${Math.random() - .toString(36) - .slice(2, 6)}`; + const defaultBranch = `codex/${new Date() + .toISOString() + .slice(0, 10)}-${Math.random().toString(36).slice(2, 6)}`; setWorktreePrompt({ workspace, branch: defaultBranch, isSubmitting: false, - error: null, + error: null }); } @@ -482,7 +581,7 @@ function MainApp() { } const { workspace, branch } = worktreePrompt; setWorktreePrompt((prev) => - prev ? { ...prev, isSubmitting: true, error: null } : prev, + prev ? { ...prev, isSubmitting: true, error: null } : prev ); try { const worktreeWorkspace = await addWorktreeAgent(workspace, branch); @@ -501,14 +600,14 @@ function MainApp() { } catch (error) { const message = error instanceof Error ? error.message : String(error); setWorktreePrompt((prev) => - prev ? { ...prev, isSubmitting: false, error: message } : prev, + prev ? { ...prev, isSubmitting: false, error: message } : prev ); addDebugEntry({ id: `${Date.now()}-client-add-worktree-error`, timestamp: Date.now(), source: "error", label: "worktree/add error", - payload: message, + payload: message }); } } @@ -536,17 +635,19 @@ function MainApp() { startLine && endLine && endLine !== startLine ? `${startLine}-${endLine}` : startLine - ? `${startLine}` - : null; - const lineLabel = lineRange ? `${reference.path}:${lineRange}` : reference.path; + ? `${startLine}` + : null; + const lineLabel = lineRange + ? `${reference.path}:${lineRange}` + : reference.path; const changeLabel = reference.type === "add" ? "added" : reference.type === "del" - ? "removed" - : reference.type === "mixed" - ? "mixed" - : "context"; + ? "removed" + : reference.type === "mixed" + ? "mixed" + : "context"; const snippet = reference.lines.join("\n").trimEnd(); const snippetBlock = snippet ? `\n\`\`\`\n${snippet}\n\`\`\`` : ""; const label = reference.lines.length > 1 ? "Line range" : "Line reference"; @@ -554,7 +655,7 @@ function MainApp() { setComposerInsert({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, text, - createdAt: Date.now(), + createdAt: Date.now() }); } @@ -577,7 +678,7 @@ function MainApp() { }; setQueuedByThread((prev) => ({ ...prev, - [activeThreadId]: [...(prev[activeThreadId] ?? []), item], + [activeThreadId]: [...(prev[activeThreadId] ?? []), item] })); clearActiveImages(); return; @@ -610,7 +711,7 @@ function MainApp() { setFlushingByThread((prev) => ({ ...prev, [threadId]: true })); setQueuedByThread((prev) => ({ ...prev, - [threadId]: (prev[threadId] ?? []).slice(1), + [threadId]: (prev[threadId] ?? []).slice(1) })); (async () => { try { @@ -622,7 +723,7 @@ function MainApp() { } catch { setQueuedByThread((prev) => ({ ...prev, - [threadId]: [nextItem, ...(prev[threadId] ?? [])], + [threadId]: [nextItem, ...(prev[threadId] ?? [])] })); } finally { setFlushingByThread((prev) => ({ ...prev, [threadId]: false })); @@ -634,7 +735,7 @@ function MainApp() { isProcessing, isReviewing, queuedByThread, - sendUserMessage, + sendUserMessage ]); const handleDebugClick = () => { @@ -651,7 +752,10 @@ function MainApp() { ? entry.settings.sortOrder : Number.MAX_SAFE_INTEGER; - const handleMoveWorkspace = async (workspaceId: string, direction: "up" | "down") => { + const handleMoveWorkspace = async ( + workspaceId: string, + direction: "up" | "down" + ) => { const ordered = workspaces .filter((entry) => (entry.kind ?? "main") !== "worktree") .slice() @@ -678,9 +782,9 @@ function MainApp() { next.map((entry, idx) => updateWorkspaceSettings(entry.id, { ...entry.settings, - sortOrder: idx, - }), - ), + sortOrder: idx + }) + ) ); }; @@ -690,21 +794,23 @@ function MainApp() { const showGitDetail = Boolean(selectedDiffPath) && isPhone; const appClassName = `app ${isCompact ? "layout-compact" : "layout-desktop"}${ isPhone ? " layout-phone" : "" - }${isTablet ? " layout-tablet" : ""}${reduceTransparency ? " reduced-transparency" : ""}`; + }${isTablet ? " layout-tablet" : ""}${ + reduceTransparency ? " reduced-transparency" : "" + }`; const sidebarNode = ( - { exitDiffView(); setActiveWorkspaceId(null); @@ -731,7 +837,7 @@ function MainApp() { } void updateWorkspaceSettings(workspaceId, { ...target.settings, - sidebarCollapsed: collapsed, + sidebarCollapsed: collapsed }); }} onSelectThread={(workspaceId, threadId) => { @@ -767,7 +873,9 @@ function MainApp() { ({ ...prev, [activeThreadId]: (prev[activeThreadId] ?? []).filter( - (entry) => entry.id !== item.id, - ), + (entry) => entry.id !== item.id + ) })); setImagesForThread(activeThreadId, item.images ?? []); setPrefillDraft(item); @@ -823,8 +933,8 @@ function MainApp() { setQueuedByThread((prev) => ({ ...prev, [activeThreadId]: (prev[activeThreadId] ?? []).filter( - (entry) => entry.id !== id, - ), + (entry) => entry.id !== id + ) })); }} models={models} @@ -898,16 +1008,16 @@ function MainApp() { worktreeLabel={worktreeLabel} disableBranchMenu={isWorktreeWorkspace} parentPath={activeParentWorkspace?.path ?? null} - worktreePath={isWorktreeWorkspace ? activeWorkspace.path : null} + worktreePath={ + isWorktreeWorkspace ? activeWorkspace.path : null + } branchName={gitStatus.branchName || "unknown"} branches={branches} onCheckoutBranch={handleCheckoutBranch} onCreateBranch={handleCreateBranch} /> -
- {null} -
+
{null}
-
+
{tabletTab === "codex" && ( <> -
- {messagesNode} -
+
{messagesNode}
{composerNode} )} @@ -1123,38 +1235,46 @@ function MainApp() { onUpdate={updater.startUpdate} onDismiss={updater.dismiss} /> - {activeTab === "projects" &&
{sidebarNode}
} + {activeTab === "projects" && ( +
{sidebarNode}
+ )} {activeTab === "codex" && (
{activeWorkspace ? ( <> -
+
- +
-
- {messagesNode} -
+
{messagesNode}
{composerNode} ) : (

No workspace selected

Choose a project to start chatting.

-
@@ -1167,7 +1287,10 @@ function MainApp() {

No workspace selected

Select a project to inspect diffs.

-
@@ -1199,20 +1322,25 @@ function MainApp() { )} {activeWorkspace && !showGitDetail && ( <> -
+
- +
@@ -1285,6 +1413,7 @@ function MainApp() { "--right-panel-width": `${rightPanelWidth}px`, "--plan-panel-height": `${planPanelHeight}px`, "--debug-panel-height": `${debugPanelHeight}px`, + "--ui-scale": String(uiScale) } as React.CSSProperties } > @@ -1298,7 +1427,7 @@ function MainApp() { isBusy={worktreePrompt.isSubmitting} onChange={(value) => setWorktreePrompt((prev) => - prev ? { ...prev, branch: value, error: null } : prev, + prev ? { ...prev, branch: value, error: null } : prev ) } onCancel={() => setWorktreePrompt(null)} @@ -1317,12 +1446,14 @@ function MainApp() { onToggleTransparency={setReduceTransparency} appSettings={appSettings} onUpdateAppSettings={async (next) => { - await saveSettings(next); + await queueSaveSettings(next); }} onRunDoctor={doctor} onUpdateWorkspaceCodexBin={async (id, codexBin) => { await updateWorkspaceCodexBin(id, codexBin); }} + scaleShortcutTitle={scaleShortcutTitle} + scaleShortcutText={scaleShortcutText} /> )}
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 1332fa77f..b16355164 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -11,6 +11,9 @@ import { X, } from "lucide-react"; import type { AppSettings, CodexDoctorResult, WorkspaceInfo } from "../types"; +import { + clampUiScale, +} from "../utils/uiScale"; type SettingsViewProps = { workspaces: WorkspaceInfo[]; @@ -23,6 +26,8 @@ type SettingsViewProps = { onUpdateAppSettings: (next: AppSettings) => Promise; onRunDoctor: (codexBin: string | null) => Promise; onUpdateWorkspaceCodexBin: (id: string, codexBin: string | null) => Promise; + scaleShortcutTitle: string; + scaleShortcutText: string; }; type SettingsSection = "projects" | "display"; @@ -44,9 +49,14 @@ export function SettingsView({ onUpdateAppSettings, onRunDoctor, onUpdateWorkspaceCodexBin, + scaleShortcutTitle, + scaleShortcutText, }: SettingsViewProps) { const [activeSection, setActiveSection] = useState("projects"); const [codexPathDraft, setCodexPathDraft] = useState(appSettings.codexBin ?? ""); + const [scaleDraft, setScaleDraft] = useState( + `${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`, + ); const [overrideDrafts, setOverrideDrafts] = useState>({}); const [doctorState, setDoctorState] = useState<{ status: "idle" | "running" | "done"; @@ -71,6 +81,10 @@ export function SettingsView({ setCodexPathDraft(appSettings.codexBin ?? ""); }, [appSettings.codexBin]); + useEffect(() => { + setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); + }, [appSettings.uiScale]); + useEffect(() => { setOverrideDrafts((prev) => { const next: Record = {}; @@ -85,6 +99,12 @@ export function SettingsView({ const codexDirty = (codexPathDraft.trim() || null) !== (appSettings.codexBin ?? null); + const trimmedScale = scaleDraft.trim(); + const parsedPercent = trimmedScale + ? Number(trimmedScale.replace("%", "")) + : Number.NaN; + const parsedScale = Number.isFinite(parsedPercent) ? parsedPercent / 100 : null; + const handleSaveCodexSettings = async () => { setIsSavingSettings(true); try { @@ -97,6 +117,34 @@ export function SettingsView({ } }; + const handleCommitScale = async () => { + if (parsedScale === null) { + setScaleDraft(`${Math.round(clampUiScale(appSettings.uiScale) * 100)}%`); + return; + } + const nextScale = clampUiScale(parsedScale); + setScaleDraft(`${Math.round(nextScale * 100)}%`); + if (nextScale === appSettings.uiScale) { + return; + } + await onUpdateAppSettings({ + ...appSettings, + uiScale: nextScale, + }); + }; + + const handleResetScale = async () => { + if (appSettings.uiScale === 1) { + setScaleDraft("100%"); + return; + } + setScaleDraft("100%"); + await onUpdateAppSettings({ + ...appSettings, + uiScale: 1, + }); + }; + const handleBrowseCodex = async () => { const selection = await open({ multiple: false, directory: false }); if (!selection || Array.isArray(selection)) { @@ -244,6 +292,46 @@ export function SettingsView({
+
+
+
Interface scale
+
+ {scaleShortcutText} +
+
+
+ setScaleDraft(event.target.value)} + onBlur={() => { + void handleCommitScale(); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleCommitScale(); + } + }} + /> + +
+
)} {activeSection === "codex" && ( diff --git a/src/hooks/useAppSettings.ts b/src/hooks/useAppSettings.ts index 9f7756ca7..9b56cab5c 100644 --- a/src/hooks/useAppSettings.ts +++ b/src/hooks/useAppSettings.ts @@ -1,12 +1,21 @@ import { useCallback, useEffect, useState } from "react"; import type { AppSettings } from "../types"; import { getAppSettings, runCodexDoctor, updateAppSettings } from "../services/tauri"; +import { clampUiScale, UI_SCALE_DEFAULT } from "../utils/uiScale"; const defaultSettings: AppSettings = { codexBin: null, defaultAccessMode: "current", + uiScale: UI_SCALE_DEFAULT, }; +function normalizeAppSettings(settings: AppSettings): AppSettings { + return { + ...settings, + uiScale: clampUiScale(settings.uiScale), + }; +} + export function useAppSettings() { const [settings, setSettings] = useState(defaultSettings); const [isLoading, setIsLoading] = useState(true); @@ -17,10 +26,12 @@ export function useAppSettings() { try { const response = await getAppSettings(); if (active) { - setSettings({ - ...defaultSettings, - ...response, - }); + setSettings( + normalizeAppSettings({ + ...defaultSettings, + ...response, + }), + ); } } finally { if (active) { @@ -34,11 +45,14 @@ export function useAppSettings() { }, []); const saveSettings = useCallback(async (next: AppSettings) => { - const saved = await updateAppSettings(next); - setSettings({ - ...defaultSettings, - ...saved, - }); + const normalized = normalizeAppSettings(next); + const saved = await updateAppSettings(normalized); + setSettings( + normalizeAppSettings({ + ...defaultSettings, + ...saved, + }), + ); return saved; }, []); diff --git a/src/hooks/useResizablePanels.ts b/src/hooks/useResizablePanels.ts index 469a8a5ef..8c0aaa17d 100644 --- a/src/hooks/useResizablePanels.ts +++ b/src/hooks/useResizablePanels.ts @@ -45,7 +45,7 @@ function readStoredWidth(key: string, fallback: number, min: number, max: number return clamp(parsed, min, max); } -export function useResizablePanels() { +export function useResizablePanels(uiScale = 1) { const [sidebarWidth, setSidebarWidth] = useState(() => readStoredWidth( STORAGE_KEY_SIDEBAR, @@ -79,6 +79,11 @@ export function useResizablePanels() { ), ); const resizeRef = useRef(null); + const scaleRef = useRef(Math.max(0.1, uiScale || 1)); + + useEffect(() => { + scaleRef.current = Math.max(0.1, uiScale || 1); + }, [uiScale]); useEffect(() => { window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(sidebarWidth)); @@ -110,8 +115,9 @@ export function useResizablePanels() { if (!resizeRef.current) { return; } + const scale = scaleRef.current; if (resizeRef.current.type === "sidebar") { - const delta = event.clientX - resizeRef.current.startX; + const delta = (event.clientX - resizeRef.current.startX) / scale; const next = clamp( resizeRef.current.startWidth + delta, MIN_SIDEBAR_WIDTH, @@ -119,7 +125,7 @@ export function useResizablePanels() { ); setSidebarWidth(next); } else if (resizeRef.current.type === "right-panel") { - const delta = event.clientX - resizeRef.current.startX; + const delta = (event.clientX - resizeRef.current.startX) / scale; const next = clamp( resizeRef.current.startWidth - delta, MIN_RIGHT_PANEL_WIDTH, @@ -127,7 +133,7 @@ export function useResizablePanels() { ); setRightPanelWidth(next); } else if (resizeRef.current.type === "plan-panel") { - const delta = event.clientY - resizeRef.current.startY; + const delta = (event.clientY - resizeRef.current.startY) / scale; const next = clamp( resizeRef.current.startHeight - delta, MIN_PLAN_PANEL_HEIGHT, @@ -135,7 +141,7 @@ export function useResizablePanels() { ); setPlanPanelHeight(next); } else { - const delta = event.clientY - resizeRef.current.startY; + const delta = (event.clientY - resizeRef.current.startY) / scale; const next = clamp( resizeRef.current.startHeight - delta, MIN_DEBUG_PANEL_HEIGHT, diff --git a/src/styles/base.css b/src/styles/base.css index 1c40542ee..c71367399 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -59,6 +59,7 @@ --status-unknown: rgba(255, 255, 255, 0.3); --select-caret: rgba(255, 255, 255, 0.6); --tabbar-height: 56px; + --ui-scale: 1; } .app.reduced-transparency { @@ -182,13 +183,16 @@ body { } .app { - height: 100vh; + height: calc(100vh / var(--ui-scale, 1)); + width: calc(100vw / var(--ui-scale, 1)); display: grid; grid-template-columns: var(--sidebar-width, 280px) 1fr; background: transparent; border-radius: 0; overflow: hidden; position: relative; + transform: scale(var(--ui-scale, 1)); + transform-origin: top left; } .drag-strip { diff --git a/src/styles/settings.css b/src/styles/settings.css index 157bed452..1e98cc658 100644 --- a/src/styles/settings.css +++ b/src/styles/settings.css @@ -160,6 +160,26 @@ font-size: 11px; } +.settings-input--scale { + flex: 0 0 auto; + width: 88px; + text-align: right; +} + + +.settings-scale-controls { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; + flex-shrink: 0; +} + +.settings-scale-reset { + padding: 8px 12px; + font-size: 12px; +} + .settings-select { padding: 10px 12px; border-radius: 10px; @@ -306,6 +326,14 @@ border: 1px solid var(--border-muted); } +.settings-toggle-row + .settings-toggle-row { + margin-top: 12px; +} + +.settings-scale-row > div:first-child { + min-width: 0; +} + .settings-toggle-title { font-size: 13px; font-weight: 600; diff --git a/src/types.ts b/src/types.ts index ada48cfe6..c947b59ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,7 @@ export type AccessMode = "read-only" | "current" | "full-access"; export type AppSettings = { codexBin: string | null; defaultAccessMode: AccessMode; + uiScale: number; }; export type CodexDoctorResult = { diff --git a/src/utils/uiScale.ts b/src/utils/uiScale.ts new file mode 100644 index 000000000..247bfaa8b --- /dev/null +++ b/src/utils/uiScale.ts @@ -0,0 +1,17 @@ +export const UI_SCALE_MIN = 0.1; +export const UI_SCALE_MAX = 3; +export const UI_SCALE_STEP = 0.1; +export const UI_SCALE_DEFAULT = 1; + +export function clampUiScale(value: number) { + if (!Number.isFinite(value)) { + return UI_SCALE_DEFAULT; + } + const rounded = Math.round(value / UI_SCALE_STEP) * UI_SCALE_STEP; + const clamped = Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, rounded)); + return Number(clamped.toFixed(1)); +} + +export function formatUiScale(value: number) { + return clampUiScale(value).toFixed(1); +}