Skip to content

Commit 22aaebc

Browse files
ildunaricodex
andcommitted
Add settings sync mode and collapsible subagent sessions
- add app_authoritative vs bidirectional settings sync mode and bidirectional merge behavior in backend settings core - add showSubagentSessions setting and sidebar collapse controls for subagent thread children - wire new settings through frontend models/layout and extend tests for settings/thread row behavior Co-authored-by: Codex <noreply@openai.com>
1 parent 0e9ceef commit 22aaebc

22 files changed

Lines changed: 532 additions & 44 deletions

src-tauri/src/shared/settings_core.rs

Lines changed: 103 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use std::path::PathBuf;
22

3+
use serde_json::Value;
34
use tokio::sync::Mutex;
45

56
use crate::codex::config as codex_config;
6-
use crate::storage::write_settings;
7-
use crate::types::AppSettings;
7+
use crate::storage::{read_settings, write_settings};
8+
use crate::types::{AppSettings, SettingsSyncMode};
89

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

1718
pub(crate) async fn get_app_settings_core(app_settings: &Mutex<AppSettings>) -> AppSettings {
1819
let mut settings = app_settings.lock().await.clone();
19-
if let Ok(Some(collaboration_modes_enabled)) = codex_config::read_collaboration_modes_enabled()
20-
{
21-
settings.collaboration_modes_enabled = collaboration_modes_enabled;
22-
}
23-
if let Ok(Some(steer_enabled)) = codex_config::read_steer_enabled() {
24-
settings.steer_enabled = steer_enabled;
25-
}
26-
if let Ok(Some(unified_exec_enabled)) = codex_config::read_unified_exec_enabled() {
27-
settings.unified_exec_enabled = unified_exec_enabled;
28-
}
29-
if let Ok(Some(apps_enabled)) = codex_config::read_apps_enabled() {
30-
settings.experimental_apps_enabled = apps_enabled;
31-
}
32-
if let Ok(personality) = codex_config::read_personality() {
33-
settings.personality = personality
34-
.as_deref()
35-
.and_then(normalize_personality)
36-
.unwrap_or("friendly")
37-
.to_string();
20+
if matches!(settings.sync_mode, SettingsSyncMode::Bidirectional) {
21+
if let Ok(Some(collaboration_modes_enabled)) = codex_config::read_collaboration_modes_enabled()
22+
{
23+
settings.collaboration_modes_enabled = collaboration_modes_enabled;
24+
}
25+
if let Ok(Some(steer_enabled)) = codex_config::read_steer_enabled() {
26+
settings.steer_enabled = steer_enabled;
27+
}
28+
if let Ok(Some(unified_exec_enabled)) = codex_config::read_unified_exec_enabled() {
29+
settings.unified_exec_enabled = unified_exec_enabled;
30+
}
31+
if let Ok(Some(apps_enabled)) = codex_config::read_apps_enabled() {
32+
settings.experimental_apps_enabled = apps_enabled;
33+
}
34+
if let Ok(personality) = codex_config::read_personality() {
35+
settings.personality = personality
36+
.as_deref()
37+
.and_then(normalize_personality)
38+
.unwrap_or("friendly")
39+
.to_string();
40+
}
3841
}
3942
settings
4043
}
@@ -44,15 +47,86 @@ pub(crate) async fn update_app_settings_core(
4447
app_settings: &Mutex<AppSettings>,
4548
settings_path: &PathBuf,
4649
) -> Result<AppSettings, String> {
47-
let _ = codex_config::write_collaboration_modes_enabled(settings.collaboration_modes_enabled);
48-
let _ = codex_config::write_steer_enabled(settings.steer_enabled);
49-
let _ = codex_config::write_unified_exec_enabled(settings.unified_exec_enabled);
50-
let _ = codex_config::write_apps_enabled(settings.experimental_apps_enabled);
51-
let _ = codex_config::write_personality(settings.personality.as_str());
52-
write_settings(settings_path, &settings)?;
50+
let previous = app_settings.lock().await.clone();
51+
let mut next = settings;
52+
53+
if matches!(next.sync_mode, SettingsSyncMode::Bidirectional) {
54+
if let Ok(disk_settings) = read_settings(settings_path) {
55+
next = merge_bidirectional_settings(previous.clone(), next, disk_settings)?;
56+
}
57+
reconcile_managed_config_fields(&previous, &mut next);
58+
}
59+
60+
let _ = codex_config::write_collaboration_modes_enabled(next.collaboration_modes_enabled);
61+
let _ = codex_config::write_steer_enabled(next.steer_enabled);
62+
let _ = codex_config::write_unified_exec_enabled(next.unified_exec_enabled);
63+
let _ = codex_config::write_apps_enabled(next.experimental_apps_enabled);
64+
let _ = codex_config::write_personality(next.personality.as_str());
65+
write_settings(settings_path, &next)?;
5366
let mut current = app_settings.lock().await;
54-
*current = settings.clone();
55-
Ok(settings)
67+
*current = next.clone();
68+
Ok(next)
69+
}
70+
71+
fn merge_bidirectional_settings(
72+
previous: AppSettings,
73+
incoming: AppSettings,
74+
disk: AppSettings,
75+
) -> Result<AppSettings, String> {
76+
let previous_value = serde_json::to_value(previous).map_err(|e| e.to_string())?;
77+
let incoming_value = serde_json::to_value(incoming.clone()).map_err(|e| e.to_string())?;
78+
let disk_value = serde_json::to_value(disk).map_err(|e| e.to_string())?;
79+
80+
let mut merged = incoming_value.clone();
81+
let (Value::Object(previous_map), Value::Object(incoming_map), Value::Object(disk_map), Value::Object(merged_map)) =
82+
(&previous_value, &incoming_value, &disk_value, &mut merged)
83+
else {
84+
return Ok(incoming);
85+
};
86+
87+
for (key, incoming_field) in incoming_map {
88+
if let Some(previous_field) = previous_map.get(key) {
89+
if incoming_field == previous_field {
90+
if let Some(disk_field) = disk_map.get(key) {
91+
merged_map.insert(key.clone(), disk_field.clone());
92+
}
93+
}
94+
}
95+
}
96+
97+
serde_json::from_value(merged).map_err(|e| e.to_string())
98+
}
99+
100+
fn reconcile_managed_config_fields(previous: &AppSettings, next: &mut AppSettings) {
101+
if next.collaboration_modes_enabled == previous.collaboration_modes_enabled {
102+
if let Ok(Some(value)) = codex_config::read_collaboration_modes_enabled() {
103+
next.collaboration_modes_enabled = value;
104+
}
105+
}
106+
if next.steer_enabled == previous.steer_enabled {
107+
if let Ok(Some(value)) = codex_config::read_steer_enabled() {
108+
next.steer_enabled = value;
109+
}
110+
}
111+
if next.unified_exec_enabled == previous.unified_exec_enabled {
112+
if let Ok(Some(value)) = codex_config::read_unified_exec_enabled() {
113+
next.unified_exec_enabled = value;
114+
}
115+
}
116+
if next.experimental_apps_enabled == previous.experimental_apps_enabled {
117+
if let Ok(Some(value)) = codex_config::read_apps_enabled() {
118+
next.experimental_apps_enabled = value;
119+
}
120+
}
121+
if next.personality == previous.personality {
122+
if let Ok(personality) = codex_config::read_personality() {
123+
next.personality = personality
124+
.as_deref()
125+
.and_then(normalize_personality)
126+
.unwrap_or("friendly")
127+
.to_string();
128+
}
129+
}
56130
}
57131

58132
pub(crate) fn get_codex_config_path_core() -> Result<String, String> {

src-tauri/src/types.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,13 @@ pub(crate) struct AppSettings {
541541
rename = "subagentSystemNotificationsEnabled"
542542
)]
543543
pub(crate) subagent_system_notifications_enabled: bool,
544+
#[serde(
545+
default = "default_show_subagent_sessions",
546+
rename = "showSubagentSessions"
547+
)]
548+
pub(crate) show_subagent_sessions: bool,
549+
#[serde(default = "default_settings_sync_mode", rename = "syncMode")]
550+
pub(crate) sync_mode: SettingsSyncMode,
544551
#[serde(
545552
default = "default_collaboration_modes_enabled",
546553
rename = "collaborationModesEnabled"
@@ -654,6 +661,19 @@ impl Default for BackendMode {
654661
}
655662
}
656663

664+
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
665+
#[serde(rename_all = "snake_case")]
666+
pub(crate) enum SettingsSyncMode {
667+
AppAuthoritative,
668+
Bidirectional,
669+
}
670+
671+
impl Default for SettingsSyncMode {
672+
fn default() -> Self {
673+
SettingsSyncMode::AppAuthoritative
674+
}
675+
}
676+
657677
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
658678
#[serde(rename_all = "lowercase")]
659679
pub(crate) enum RemoteBackendProvider {
@@ -882,6 +902,14 @@ fn default_subagent_system_notifications_enabled() -> bool {
882902
true
883903
}
884904

905+
fn default_show_subagent_sessions() -> bool {
906+
true
907+
}
908+
909+
fn default_settings_sync_mode() -> SettingsSyncMode {
910+
SettingsSyncMode::AppAuthoritative
911+
}
912+
885913
fn default_split_chat_diff_view() -> bool {
886914
false
887915
}
@@ -1152,6 +1180,8 @@ impl Default for AppSettings {
11521180
notification_sounds_enabled: true,
11531181
system_notifications_enabled: true,
11541182
subagent_system_notifications_enabled: true,
1183+
show_subagent_sessions: true,
1184+
sync_mode: SettingsSyncMode::AppAuthoritative,
11551185
split_chat_diff_view: default_split_chat_diff_view(),
11561186
preload_git_diffs: default_preload_git_diffs(),
11571187
git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(),

src/App.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
import Plus from "lucide-react/dist/esm/icons/plus";
23
import RefreshCw from "lucide-react/dist/esm/icons/refresh-cw";
34
import "./styles/base.css";
45
import "./styles/ds-tokens.css";
@@ -219,6 +220,8 @@ function MainApp() {
219220
"home" | "projects" | "codex" | "git" | "log"
220221
>("codex");
221222
const [mobileThreadRefreshLoading, setMobileThreadRefreshLoading] = useState(false);
223+
const [lastCodexWorkspaceId, setLastCodexWorkspaceId] = useState<string | null>(null);
224+
const [lastCodexThreadId, setLastCodexThreadId] = useState<string | null>(null);
222225
const tabletTab =
223226
activeTab === "projects" || activeTab === "home" ? "codex" : activeTab;
224227
const {
@@ -689,6 +692,23 @@ function MainApp() {
689692
threadSortKey: threadListSortKey,
690693
onThreadCodexMetadataDetected: handleThreadCodexMetadataDetected,
691694
});
695+
696+
useEffect(() => {
697+
if (!isPhone || !activeWorkspaceId) {
698+
return;
699+
}
700+
setLastCodexWorkspaceId(activeWorkspaceId);
701+
}, [activeWorkspaceId, isPhone]);
702+
703+
useEffect(() => {
704+
if (!isPhone || !activeWorkspaceId || !activeThreadId) {
705+
return;
706+
}
707+
const workspaceThreads = threadsByWorkspace[activeWorkspaceId] ?? [];
708+
if (workspaceThreads.some((thread) => thread.id === activeThreadId)) {
709+
setLastCodexThreadId(activeThreadId);
710+
}
711+
}, [activeThreadId, activeWorkspaceId, isPhone, threadsByWorkspace]);
692712
const { connectionState: remoteThreadConnectionState, reconnectLive } =
693713
useRemoteThreadLiveConnection({
694714
backendMode: appSettings.backendMode,
@@ -2036,6 +2056,8 @@ function MainApp() {
20362056
Boolean(activeWorkspace) &&
20372057
isCompact &&
20382058
((isPhone && activeTab === "codex") || (isTablet && tabletTab === "codex"));
2059+
const showPhoneCodexNewChatAction =
2060+
Boolean(activeWorkspace) && isCompact && isPhone && activeTab === "codex";
20392061
const showMobilePollingFetchStatus =
20402062
showCompactCodexThreadActions &&
20412063
Boolean(activeWorkspace?.connected) &&
@@ -2090,6 +2112,7 @@ function MainApp() {
20902112
pollingIntervalMs: REMOTE_THREAD_POLL_INTERVAL_MS,
20912113
activeRateLimits,
20922114
usageShowRemaining: appSettings.usageShowRemaining,
2115+
showSubagentSessions: appSettings.showSubagentSessions,
20932116
accountInfo: activeAccount,
20942117
onSwitchAccount: handleSwitchAccount,
20952118
onCancelSwitchAccount: handleCancelSwitchAccount,
@@ -2193,6 +2216,22 @@ function MainApp() {
21932216
launchScriptsState,
21942217
mainHeaderActionsNode: (
21952218
<>
2219+
{showPhoneCodexNewChatAction ? (
2220+
<button
2221+
type="button"
2222+
className="ghost main-header-action"
2223+
onClick={() => {
2224+
if (activeWorkspace) {
2225+
void handleAddAgent(activeWorkspace);
2226+
}
2227+
}}
2228+
data-tauri-drag-region="false"
2229+
aria-label="Start new chat in this workspace"
2230+
title="Start new chat in this workspace"
2231+
>
2232+
<Plus size={14} aria-hidden />
2233+
</button>
2234+
) : null}
21962235
{showCompactCodexThreadActions ? (
21972236
<button
21982237
type="button"
@@ -2237,6 +2276,18 @@ function MainApp() {
22372276
selectHome();
22382277
return;
22392278
}
2279+
if (tab === "codex" && isPhone && !activeWorkspace && lastCodexWorkspaceId) {
2280+
const workspace = workspacesById.get(lastCodexWorkspaceId);
2281+
if (workspace) {
2282+
selectWorkspace(lastCodexWorkspaceId);
2283+
if (lastCodexThreadId) {
2284+
const workspaceThreads = threadsByWorkspace[lastCodexWorkspaceId] ?? [];
2285+
if (workspaceThreads.some((thread) => thread.id === lastCodexThreadId)) {
2286+
setActiveThreadId(lastCodexThreadId, lastCodexWorkspaceId);
2287+
}
2288+
}
2289+
}
2290+
}
22402291
setActiveTab(tab);
22412292
},
22422293
tabletNavTab: tabletTab,

src/features/app/components/PinnedThreadList.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const statusMap = {
2222
};
2323

2424
const baseProps = {
25-
rows: [{ thread, depth: 0, workspaceId: "ws-1" }],
25+
rows: [{ thread, depth: 0, hasChildren: false, workspaceId: "ws-1" }],
2626
activeWorkspaceId: "ws-1",
2727
activeThreadId: "thread-1",
2828
threadStatusById: statusMap,
@@ -76,8 +76,8 @@ describe("PinnedThreadList", () => {
7676
<PinnedThreadList
7777
{...baseProps}
7878
rows={[
79-
{ thread, depth: 0, workspaceId: "ws-1" },
80-
{ thread: otherThread, depth: 0, workspaceId: "ws-2" },
79+
{ thread, depth: 0, hasChildren: false, workspaceId: "ws-1" },
80+
{ thread: otherThread, depth: 0, hasChildren: false, workspaceId: "ws-2" },
8181
]}
8282
onSelectThread={onSelectThread}
8383
onShowThreadMenu={onShowThreadMenu}
@@ -106,7 +106,7 @@ describe("PinnedThreadList", () => {
106106
const { container } = render(
107107
<PinnedThreadList
108108
{...baseProps}
109-
rows={[{ thread: otherThread, depth: 0, workspaceId: "ws-2" }]}
109+
rows={[{ thread: otherThread, depth: 0, hasChildren: false, workspaceId: "ws-2" }]}
110110
threadStatusById={{
111111
"thread-1": { isProcessing: false, hasUnread: false, isReviewing: true },
112112
"thread-2": { isProcessing: true, hasUnread: false, isReviewing: false },

src/features/app/components/PinnedThreadList.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ThreadRow } from "./ThreadRow";
77
type PinnedThreadRow = {
88
thread: ThreadSummary;
99
depth: number;
10+
hasChildren: boolean;
1011
workspaceId: string;
1112
};
1213

@@ -27,6 +28,8 @@ type PinnedThreadListProps = {
2728
threadId: string,
2829
canPin: boolean,
2930
) => void;
31+
collapsedThreadIdsByWorkspace?: Record<string, ReadonlySet<string>>;
32+
onToggleThreadChildren?: (workspaceId: string, threadId: string) => void;
3033
};
3134

3235
export function PinnedThreadList({
@@ -41,15 +44,18 @@ export function PinnedThreadList({
4144
isThreadPinned,
4245
onSelectThread,
4346
onShowThreadMenu,
47+
collapsedThreadIdsByWorkspace,
48+
onToggleThreadChildren,
4449
}: PinnedThreadListProps) {
4550
return (
4651
<div className="thread-list pinned-thread-list">
47-
{rows.map(({ thread, depth, workspaceId }) => {
52+
{rows.map(({ thread, depth, hasChildren, workspaceId }) => {
4853
return (
4954
<ThreadRow
5055
key={`${workspaceId}:${thread.id}`}
5156
thread={thread}
5257
depth={depth}
58+
hasChildren={hasChildren}
5359
workspaceId={workspaceId}
5460
indentUnit={14}
5561
activeWorkspaceId={activeWorkspaceId}
@@ -62,6 +68,8 @@ export function PinnedThreadList({
6268
isThreadPinned={isThreadPinned}
6369
onSelectThread={onSelectThread}
6470
onShowThreadMenu={onShowThreadMenu}
71+
isCollapsed={Boolean(collapsedThreadIdsByWorkspace?.[workspaceId]?.has(thread.id))}
72+
onToggleThreadChildren={onToggleThreadChildren}
6573
/>
6674
);
6775
})}

0 commit comments

Comments
 (0)