Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 103 additions & 29 deletions src-tauri/src/shared/settings_core.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
use std::path::PathBuf;

use serde_json::Value;
use tokio::sync::Mutex;

use crate::codex::config as codex_config;
use crate::storage::write_settings;
use crate::types::AppSettings;
use crate::storage::{read_settings, write_settings};
use crate::types::{AppSettings, SettingsSyncMode};

fn normalize_personality(value: &str) -> Option<&'static str> {
match value.trim() {
Expand All @@ -16,25 +17,27 @@ fn normalize_personality(value: &str) -> Option<&'static str> {

pub(crate) async fn get_app_settings_core(app_settings: &Mutex<AppSettings>) -> AppSettings {
let mut settings = app_settings.lock().await.clone();
if let Ok(Some(collaboration_modes_enabled)) = codex_config::read_collaboration_modes_enabled()
{
settings.collaboration_modes_enabled = collaboration_modes_enabled;
}
if let Ok(Some(steer_enabled)) = codex_config::read_steer_enabled() {
settings.steer_enabled = steer_enabled;
}
if let Ok(Some(unified_exec_enabled)) = codex_config::read_unified_exec_enabled() {
settings.unified_exec_enabled = unified_exec_enabled;
}
if let Ok(Some(apps_enabled)) = codex_config::read_apps_enabled() {
settings.experimental_apps_enabled = apps_enabled;
}
if let Ok(personality) = codex_config::read_personality() {
settings.personality = personality
.as_deref()
.and_then(normalize_personality)
.unwrap_or("friendly")
.to_string();
if matches!(settings.sync_mode, SettingsSyncMode::Bidirectional) {
if let Ok(Some(collaboration_modes_enabled)) = codex_config::read_collaboration_modes_enabled()
{
settings.collaboration_modes_enabled = collaboration_modes_enabled;
}
if let Ok(Some(steer_enabled)) = codex_config::read_steer_enabled() {
settings.steer_enabled = steer_enabled;
}
if let Ok(Some(unified_exec_enabled)) = codex_config::read_unified_exec_enabled() {
settings.unified_exec_enabled = unified_exec_enabled;
}
if let Ok(Some(apps_enabled)) = codex_config::read_apps_enabled() {
settings.experimental_apps_enabled = apps_enabled;
}
if let Ok(personality) = codex_config::read_personality() {
settings.personality = personality
.as_deref()
.and_then(normalize_personality)
.unwrap_or("friendly")
.to_string();
}
}
Comment on lines 18 to 41
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says existing behavior is preserved unless sync mode is switched to bidirectional, but get_app_settings_core now only pulls values from codex_config when sync_mode is Bidirectional. With the new default (AppAuthoritative), manual edits to the managed Codex config fields will no longer be reflected on reads. If that behavior change is intentional, it may need to be called out explicitly; otherwise consider still reconciling these fields on read in app-authoritative mode (or revising the default).

Copilot uses AI. Check for mistakes.
settings
}
Expand All @@ -44,15 +47,86 @@ pub(crate) async fn update_app_settings_core(
app_settings: &Mutex<AppSettings>,
settings_path: &PathBuf,
) -> Result<AppSettings, String> {
let _ = codex_config::write_collaboration_modes_enabled(settings.collaboration_modes_enabled);
let _ = codex_config::write_steer_enabled(settings.steer_enabled);
let _ = codex_config::write_unified_exec_enabled(settings.unified_exec_enabled);
let _ = codex_config::write_apps_enabled(settings.experimental_apps_enabled);
let _ = codex_config::write_personality(settings.personality.as_str());
write_settings(settings_path, &settings)?;
let previous = app_settings.lock().await.clone();
let mut next = settings;

if matches!(next.sync_mode, SettingsSyncMode::Bidirectional) {
if let Ok(disk_settings) = read_settings(settings_path) {
Comment on lines +50 to +54

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rebase bidirectional merge on fresh settings state

This reconciliation path uses previous from the in-memory mutex as the merge baseline, but in bidirectional mode get_app_settings_core overlays Codex-managed values on a clone and does not persist them back into that mutex. If Codex config changes while the settings UI is open, untouched managed fields can be misclassified as user-edited (incoming != previous), which skips reconcile_managed_config_fields and causes the later writes to overwrite newer external config edits. Recomputing the baseline from current persisted/synced values before merge would prevent these lost updates.

Useful? React with 👍 / 👎.

next = merge_bidirectional_settings(previous.clone(), next, disk_settings)?;
}
Comment on lines +54 to +56
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In bidirectional sync mode, a failure to read the on-disk settings is silently ignored (if let Ok(disk_settings) = read_settings(...)). That means a JSON parse error or other read failure can cause the app to proceed and then overwrite the file with write_settings, potentially discarding a user's manual edits without surfacing any error. Consider propagating the read_settings error (or at least aborting the write) when sync_mode is Bidirectional so users can fix the file instead of losing changes.

Suggested change
if let Ok(disk_settings) = read_settings(settings_path) {
next = merge_bidirectional_settings(previous.clone(), next, disk_settings)?;
}
let disk_settings = read_settings(settings_path)
.map_err(|e| format!("Failed to read settings for bidirectional sync: {}", e))?;
next = merge_bidirectional_settings(previous.clone(), next, disk_settings)?;

Copilot uses AI. Check for mistakes.
reconcile_managed_config_fields(&previous, &mut next);
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge_bidirectional_settings can also overwrite syncMode itself (since it merges all keys when incoming == previous). Because the if matches!(next.sync_mode, Bidirectional) check happens before the merge, reconcile_managed_config_fields will still run even if the merge changes next.sync_mode to AppAuthoritative based on disk. Consider either (a) excluding syncMode from the merge so the mode is always app-driven, or (b) re-checking next.sync_mode after the merge before running bidirectional-only reconciliation logic.

Suggested change
reconcile_managed_config_fields(&previous, &mut next);
if matches!(next.sync_mode, SettingsSyncMode::Bidirectional) {
reconcile_managed_config_fields(&previous, &mut next);
}

Copilot uses AI. Check for mistakes.
}

let _ = codex_config::write_collaboration_modes_enabled(next.collaboration_modes_enabled);
let _ = codex_config::write_steer_enabled(next.steer_enabled);
let _ = codex_config::write_unified_exec_enabled(next.unified_exec_enabled);
let _ = codex_config::write_apps_enabled(next.experimental_apps_enabled);
let _ = codex_config::write_personality(next.personality.as_str());
write_settings(settings_path, &next)?;
let mut current = app_settings.lock().await;
*current = settings.clone();
Ok(settings)
*current = next.clone();
Ok(next)
}

fn merge_bidirectional_settings(
previous: AppSettings,
incoming: AppSettings,
disk: AppSettings,
) -> Result<AppSettings, String> {
let previous_value = serde_json::to_value(previous).map_err(|e| e.to_string())?;
let incoming_value = serde_json::to_value(incoming.clone()).map_err(|e| e.to_string())?;
let disk_value = serde_json::to_value(disk).map_err(|e| e.to_string())?;

let mut merged = incoming_value.clone();
let (Value::Object(previous_map), Value::Object(incoming_map), Value::Object(disk_map), Value::Object(merged_map)) =
(&previous_value, &incoming_value, &disk_value, &mut merged)
else {
return Ok(incoming);
};

for (key, incoming_field) in incoming_map {
if let Some(previous_field) = previous_map.get(key) {
if incoming_field == previous_field {
if let Some(disk_field) = disk_map.get(key) {
merged_map.insert(key.clone(), disk_field.clone());
}
}
}
}

serde_json::from_value(merged).map_err(|e| e.to_string())
}

fn reconcile_managed_config_fields(previous: &AppSettings, next: &mut AppSettings) {
if next.collaboration_modes_enabled == previous.collaboration_modes_enabled {
if let Ok(Some(value)) = codex_config::read_collaboration_modes_enabled() {
next.collaboration_modes_enabled = value;
}
}
if next.steer_enabled == previous.steer_enabled {
if let Ok(Some(value)) = codex_config::read_steer_enabled() {
next.steer_enabled = value;
}
}
if next.unified_exec_enabled == previous.unified_exec_enabled {
if let Ok(Some(value)) = codex_config::read_unified_exec_enabled() {
next.unified_exec_enabled = value;
}
}
if next.experimental_apps_enabled == previous.experimental_apps_enabled {
if let Ok(Some(value)) = codex_config::read_apps_enabled() {
next.experimental_apps_enabled = value;
}
}
if next.personality == previous.personality {
if let Ok(personality) = codex_config::read_personality() {
next.personality = personality
.as_deref()
.and_then(normalize_personality)
.unwrap_or("friendly")
.to_string();
}
}
}

pub(crate) fn get_codex_config_path_core() -> Result<String, String> {
Expand Down
30 changes: 30 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,13 @@ pub(crate) struct AppSettings {
rename = "subagentSystemNotificationsEnabled"
)]
pub(crate) subagent_system_notifications_enabled: bool,
#[serde(
default = "default_show_subagent_sessions",
rename = "showSubagentSessions"
)]
pub(crate) show_subagent_sessions: bool,
#[serde(default = "default_settings_sync_mode", rename = "syncMode")]
pub(crate) sync_mode: SettingsSyncMode,
#[serde(
default = "default_collaboration_modes_enabled",
rename = "collaborationModesEnabled"
Expand Down Expand Up @@ -654,6 +661,19 @@ impl Default for BackendMode {
}
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum SettingsSyncMode {
AppAuthoritative,
Bidirectional,
}

impl Default for SettingsSyncMode {
fn default() -> Self {
SettingsSyncMode::AppAuthoritative
}
}

#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum RemoteBackendProvider {
Expand Down Expand Up @@ -882,6 +902,14 @@ fn default_subagent_system_notifications_enabled() -> bool {
true
}

fn default_show_subagent_sessions() -> bool {
true
}

fn default_settings_sync_mode() -> SettingsSyncMode {
SettingsSyncMode::AppAuthoritative
}

fn default_split_chat_diff_view() -> bool {
false
}
Expand Down Expand Up @@ -1152,6 +1180,8 @@ impl Default for AppSettings {
notification_sounds_enabled: true,
system_notifications_enabled: true,
subagent_system_notifications_enabled: true,
show_subagent_sessions: true,
sync_mode: SettingsSyncMode::AppAuthoritative,
split_chat_diff_view: default_split_chat_diff_view(),
preload_git_diffs: default_preload_git_diffs(),
git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(),
Expand Down
51 changes: 51 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Plus from "lucide-react/dist/esm/icons/plus";
import RefreshCw from "lucide-react/dist/esm/icons/refresh-cw";
import "./styles/base.css";
import "./styles/ds-tokens.css";
Expand Down Expand Up @@ -219,6 +220,8 @@ function MainApp() {
"home" | "projects" | "codex" | "git" | "log"
>("codex");
const [mobileThreadRefreshLoading, setMobileThreadRefreshLoading] = useState(false);
const [lastCodexWorkspaceId, setLastCodexWorkspaceId] = useState<string | null>(null);
const [lastCodexThreadId, setLastCodexThreadId] = useState<string | null>(null);
const tabletTab =
activeTab === "projects" || activeTab === "home" ? "codex" : activeTab;
const {
Expand Down Expand Up @@ -689,6 +692,23 @@ function MainApp() {
threadSortKey: threadListSortKey,
onThreadCodexMetadataDetected: handleThreadCodexMetadataDetected,
});

useEffect(() => {
if (!isPhone || !activeWorkspaceId) {
return;
}
setLastCodexWorkspaceId(activeWorkspaceId);
}, [activeWorkspaceId, isPhone]);

useEffect(() => {
if (!isPhone || !activeWorkspaceId || !activeThreadId) {
return;
}
const workspaceThreads = threadsByWorkspace[activeWorkspaceId] ?? [];
if (workspaceThreads.some((thread) => thread.id === activeThreadId)) {
setLastCodexThreadId(activeThreadId);
}
}, [activeThreadId, activeWorkspaceId, isPhone, threadsByWorkspace]);
const { connectionState: remoteThreadConnectionState, reconnectLive } =
useRemoteThreadLiveConnection({
backendMode: appSettings.backendMode,
Expand Down Expand Up @@ -2036,6 +2056,8 @@ function MainApp() {
Boolean(activeWorkspace) &&
isCompact &&
((isPhone && activeTab === "codex") || (isTablet && tabletTab === "codex"));
const showPhoneCodexNewChatAction =
Boolean(activeWorkspace) && isCompact && isPhone && activeTab === "codex";
const showMobilePollingFetchStatus =
showCompactCodexThreadActions &&
Boolean(activeWorkspace?.connected) &&
Expand Down Expand Up @@ -2090,6 +2112,7 @@ function MainApp() {
pollingIntervalMs: REMOTE_THREAD_POLL_INTERVAL_MS,
activeRateLimits,
usageShowRemaining: appSettings.usageShowRemaining,
showSubagentSessions: appSettings.showSubagentSessions,
accountInfo: activeAccount,
onSwitchAccount: handleSwitchAccount,
onCancelSwitchAccount: handleCancelSwitchAccount,
Expand Down Expand Up @@ -2193,6 +2216,22 @@ function MainApp() {
launchScriptsState,
mainHeaderActionsNode: (
<>
{showPhoneCodexNewChatAction ? (
<button
type="button"
className="ghost main-header-action"
onClick={() => {
if (activeWorkspace) {
void handleAddAgent(activeWorkspace);
}
}}
data-tauri-drag-region="false"
aria-label="Start new chat in this workspace"
title="Start new chat in this workspace"
>
<Plus size={14} aria-hidden />
</button>
) : null}
{showCompactCodexThreadActions ? (
<button
type="button"
Expand Down Expand Up @@ -2237,6 +2276,18 @@ function MainApp() {
selectHome();
return;
}
if (tab === "codex" && isPhone && !activeWorkspace && lastCodexWorkspaceId) {
const workspace = workspacesById.get(lastCodexWorkspaceId);
if (workspace) {
selectWorkspace(lastCodexWorkspaceId);
if (lastCodexThreadId) {
const workspaceThreads = threadsByWorkspace[lastCodexWorkspaceId] ?? [];
if (workspaceThreads.some((thread) => thread.id === lastCodexThreadId)) {
setActiveThreadId(lastCodexThreadId, lastCodexWorkspaceId);
}
}
}
}
setActiveTab(tab);
},
tabletNavTab: tabletTab,
Expand Down
8 changes: 4 additions & 4 deletions src/features/app/components/PinnedThreadList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const statusMap = {
};

const baseProps = {
rows: [{ thread, depth: 0, workspaceId: "ws-1" }],
rows: [{ thread, depth: 0, hasChildren: false, workspaceId: "ws-1" }],
activeWorkspaceId: "ws-1",
activeThreadId: "thread-1",
threadStatusById: statusMap,
Expand Down Expand Up @@ -76,8 +76,8 @@ describe("PinnedThreadList", () => {
<PinnedThreadList
{...baseProps}
rows={[
{ thread, depth: 0, workspaceId: "ws-1" },
{ thread: otherThread, depth: 0, workspaceId: "ws-2" },
{ thread, depth: 0, hasChildren: false, workspaceId: "ws-1" },
{ thread: otherThread, depth: 0, hasChildren: false, workspaceId: "ws-2" },
]}
onSelectThread={onSelectThread}
onShowThreadMenu={onShowThreadMenu}
Expand Down Expand Up @@ -106,7 +106,7 @@ describe("PinnedThreadList", () => {
const { container } = render(
<PinnedThreadList
{...baseProps}
rows={[{ thread: otherThread, depth: 0, workspaceId: "ws-2" }]}
rows={[{ thread: otherThread, depth: 0, hasChildren: false, workspaceId: "ws-2" }]}
threadStatusById={{
"thread-1": { isProcessing: false, hasUnread: false, isReviewing: true },
"thread-2": { isProcessing: true, hasUnread: false, isReviewing: false },
Expand Down
10 changes: 9 additions & 1 deletion src/features/app/components/PinnedThreadList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ThreadRow } from "./ThreadRow";
type PinnedThreadRow = {
thread: ThreadSummary;
depth: number;
hasChildren: boolean;
workspaceId: string;
};

Expand All @@ -27,6 +28,8 @@ type PinnedThreadListProps = {
threadId: string,
canPin: boolean,
) => void;
collapsedThreadIdsByWorkspace?: Record<string, ReadonlySet<string>>;
onToggleThreadChildren?: (workspaceId: string, threadId: string) => void;
};

export function PinnedThreadList({
Expand All @@ -41,15 +44,18 @@ export function PinnedThreadList({
isThreadPinned,
onSelectThread,
onShowThreadMenu,
collapsedThreadIdsByWorkspace,
onToggleThreadChildren,
}: PinnedThreadListProps) {
return (
<div className="thread-list pinned-thread-list">
{rows.map(({ thread, depth, workspaceId }) => {
{rows.map(({ thread, depth, hasChildren, workspaceId }) => {
return (
<ThreadRow
key={`${workspaceId}:${thread.id}`}
thread={thread}
depth={depth}
hasChildren={hasChildren}
workspaceId={workspaceId}
indentUnit={14}
activeWorkspaceId={activeWorkspaceId}
Expand All @@ -62,6 +68,8 @@ export function PinnedThreadList({
isThreadPinned={isThreadPinned}
onSelectThread={onSelectThread}
onShowThreadMenu={onShowThreadMenu}
isCollapsed={Boolean(collapsedThreadIdsByWorkspace?.[workspaceId]?.has(thread.id))}
onToggleThreadChildren={onToggleThreadChildren}
/>
);
})}
Expand Down
Loading
Loading