diff --git a/src-tauri/src/shared/settings_core.rs b/src-tauri/src/shared/settings_core.rs index 211fcf666..89ce0f861 100644 --- a/src-tauri/src/shared/settings_core.rs +++ b/src-tauri/src/shared/settings_core.rs @@ -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() { @@ -16,25 +17,27 @@ fn normalize_personality(value: &str) -> Option<&'static str> { pub(crate) async fn get_app_settings_core(app_settings: &Mutex) -> 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(); + } } settings } @@ -44,15 +47,86 @@ pub(crate) async fn update_app_settings_core( app_settings: &Mutex, settings_path: &PathBuf, ) -> Result { - 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) { + next = merge_bidirectional_settings(previous.clone(), next, disk_settings)?; + } + reconcile_managed_config_fields(&previous, &mut next); + } + + 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 { + 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 { diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index cbb8afbaa..353ffdec9 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -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" @@ -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 { @@ -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 } @@ -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(), diff --git a/src/App.tsx b/src/App.tsx index e9ac6d8b5..3b4e8e441 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"; @@ -219,6 +220,8 @@ function MainApp() { "home" | "projects" | "codex" | "git" | "log" >("codex"); const [mobileThreadRefreshLoading, setMobileThreadRefreshLoading] = useState(false); + const [lastCodexWorkspaceId, setLastCodexWorkspaceId] = useState(null); + const [lastCodexThreadId, setLastCodexThreadId] = useState(null); const tabletTab = activeTab === "projects" || activeTab === "home" ? "codex" : activeTab; const { @@ -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, @@ -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) && @@ -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, @@ -2193,6 +2216,22 @@ function MainApp() { launchScriptsState, mainHeaderActionsNode: ( <> + {showPhoneCodexNewChatAction ? ( + + ) : null} {showCompactCodexThreadActions ? ( + )} {isPinned && 📌} {thread.name} diff --git a/src/features/app/components/WorktreeSection.test.tsx b/src/features/app/components/WorktreeSection.test.tsx index de168687a..94d25e8a0 100644 --- a/src/features/app/components/WorktreeSection.test.tsx +++ b/src/features/app/components/WorktreeSection.test.tsx @@ -46,6 +46,7 @@ describe("WorktreeSection", () => { onShowWorktreeMenu={vi.fn()} onToggleExpanded={vi.fn()} onLoadOlderThreads={vi.fn()} + showSubagentSessions />, ); diff --git a/src/features/app/components/WorktreeSection.tsx b/src/features/app/components/WorktreeSection.tsx index e96024966..2853df17f 100644 --- a/src/features/app/components/WorktreeSection.tsx +++ b/src/features/app/components/WorktreeSection.tsx @@ -8,8 +8,8 @@ import { ThreadLoading } from "./ThreadLoading"; import { WorktreeCard } from "./WorktreeCard"; type ThreadRowsResult = { - pinnedRows: Array<{ thread: ThreadSummary; depth: number }>; - unpinnedRows: Array<{ thread: ThreadSummary; depth: number }>; + pinnedRows: Array<{ thread: ThreadSummary; depth: number; hasChildren: boolean }>; + unpinnedRows: Array<{ thread: ThreadSummary; depth: number; hasChildren: boolean }>; totalRoots: number; hasMoreRoots: boolean; }; @@ -32,6 +32,10 @@ type WorktreeSectionProps = { workspaceId: string, getPinTimestamp: (workspaceId: string, threadId: string) => number | null, pinVersion?: number, + options?: { + showSubagentSessions?: boolean; + collapsedParentThreadIds?: ReadonlySet; + }, ) => ThreadRowsResult; getThreadTime: (thread: ThreadSummary) => string | null; getThreadArgsBadge?: (workspaceId: string, threadId: string) => string | null; @@ -51,6 +55,9 @@ type WorktreeSectionProps = { onShowWorktreeMenu: (event: MouseEvent, worktree: WorkspaceInfo) => void; onToggleExpanded: (workspaceId: string) => void; onLoadOlderThreads: (workspaceId: string) => void; + showSubagentSessions: boolean; + collapsedThreadIdsByWorkspace?: Record>; + onToggleThreadChildren?: (workspaceId: string, threadId: string) => void; sectionLabel?: string; sectionIcon?: ReactNode; className?: string; @@ -82,6 +89,9 @@ export function WorktreeSection({ onShowWorktreeMenu, onToggleExpanded, onLoadOlderThreads, + showSubagentSessions, + collapsedThreadIdsByWorkspace, + onToggleThreadChildren, sectionLabel = "Worktrees", sectionIcon, className, @@ -119,6 +129,10 @@ export function WorktreeSection({ worktree.id, getPinTimestamp, pinnedThreadsVersion, + { + showSubagentSessions, + collapsedParentThreadIds: collapsedThreadIdsByWorkspace?.[worktree.id], + }, ); return ( @@ -150,6 +164,8 @@ export function WorktreeSection({ getThreadTime={getThreadTime} getThreadArgsBadge={getThreadArgsBadge} isThreadPinned={isThreadPinned} + collapsedThreadIds={collapsedThreadIdsByWorkspace?.[worktree.id]} + onToggleThreadChildren={onToggleThreadChildren} onToggleExpanded={onToggleExpanded} onLoadOlderThreads={onLoadOlderThreads} onSelectThread={onSelectThread} diff --git a/src/features/app/hooks/useThreadRows.test.tsx b/src/features/app/hooks/useThreadRows.test.tsx index 2b1231183..151f80986 100644 --- a/src/features/app/hooks/useThreadRows.test.tsx +++ b/src/features/app/hooks/useThreadRows.test.tsx @@ -125,4 +125,35 @@ describe("useThreadRows", () => { ["thread-child", 1], ]); }); + + it("supports hiding and collapsing subagent rows", () => { + const threads: ThreadSummary[] = [ + { id: "thread-root", name: "Root", updatedAt: 1 }, + { id: "thread-child", name: "Child", updatedAt: 2 }, + ]; + const getPinTimestamp = vi.fn(() => null); + const { result } = renderHook(() => + useThreadRows({ "thread-child": "thread-root" }), + ); + + const hidden = result.current.getThreadRows( + threads, + true, + "ws-1", + getPinTimestamp, + 0, + { showSubagentSessions: false }, + ); + expect(hidden.unpinnedRows.map((row) => row.thread.id)).toEqual(["thread-root"]); + + const collapsed = result.current.getThreadRows( + threads, + true, + "ws-1", + getPinTimestamp, + 0, + { showSubagentSessions: true, collapsedParentThreadIds: new Set(["thread-root"]) }, + ); + expect(collapsed.unpinnedRows.map((row) => row.thread.id)).toEqual(["thread-root"]); + }); }); diff --git a/src/features/app/hooks/useThreadRows.ts b/src/features/app/hooks/useThreadRows.ts index e60b0a43c..f5b034f0b 100644 --- a/src/features/app/hooks/useThreadRows.ts +++ b/src/features/app/hooks/useThreadRows.ts @@ -5,6 +5,7 @@ import type { ThreadSummary } from "../../../types"; type ThreadRow = { thread: ThreadSummary; depth: number; + hasChildren: boolean; }; type ThreadRowResult = { @@ -19,6 +20,11 @@ type ThreadRowCacheEntry = { result: ThreadRowResult; }; +type GetThreadRowsOptions = { + showSubagentSessions?: boolean; + collapsedParentThreadIds?: ReadonlySet; +}; + export function useThreadRows(threadParentById: Record) { const cacheRef = useRef( new WeakMap< @@ -42,8 +48,13 @@ export function useThreadRows(threadParentById: Record) { workspaceId: string, getPinTimestamp: (workspaceId: string, threadId: string) => number | null, pinVersion = 0, + options?: GetThreadRowsOptions, ): ThreadRowResult => { - const cacheKey = `${workspaceId}:${isExpanded ? "1" : "0"}`; + const showSubagentSessions = options?.showSubagentSessions ?? true; + const collapsedKey = options?.collapsedParentThreadIds + ? [...options.collapsedParentThreadIds].sort().join(",") + : ""; + const cacheKey = `${workspaceId}:${isExpanded ? "1" : "0"}:${showSubagentSessions ? "1" : "0"}:${collapsedKey}`; const threadCache = cacheRef.current.get(threads); const cachedEntry = threadCache?.get(cacheKey); if (cachedEntry && cachedEntry.pinVersion === pinVersion) { @@ -105,8 +116,15 @@ export function useThreadRows(threadParentById: Record) { depth: number, rows: ThreadRow[], ) => { - rows.push({ thread, depth }); const children = childrenByParent.get(thread.id) ?? []; + const hasChildren = showSubagentSessions && children.length > 0; + rows.push({ thread, depth, hasChildren }); + if (!showSubagentSessions) { + return; + } + if (options?.collapsedParentThreadIds?.has(thread.id)) { + return; + } children.forEach((child) => appendThread(child, depth + 1, rows)); }; diff --git a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx index d4958f910..9fad8d32c 100644 --- a/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx +++ b/src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx @@ -56,6 +56,7 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod userInputRequests={options.userInputRequests} accountRateLimits={options.activeRateLimits} usageShowRemaining={options.usageShowRemaining} + showSubagentSessions={options.showSubagentSessions} accountInfo={options.accountInfo} onSwitchAccount={options.onSwitchAccount} onCancelSwitchAccount={options.onCancelSwitchAccount} diff --git a/src/features/layout/hooks/layoutNodes/types.ts b/src/features/layout/hooks/layoutNodes/types.ts index 846c9d6f4..75587d4c8 100644 --- a/src/features/layout/hooks/layoutNodes/types.ts +++ b/src/features/layout/hooks/layoutNodes/types.ts @@ -129,6 +129,7 @@ export type LayoutNodesOptions = { pollingIntervalMs?: number; activeRateLimits: RateLimitSnapshot | null; usageShowRemaining: boolean; + showSubagentSessions: boolean; accountInfo: AccountSnapshot | null; onSwitchAccount: () => void; onCancelSwitchAccount: () => void; diff --git a/src/features/settings/components/SettingsView.test.tsx b/src/features/settings/components/SettingsView.test.tsx index e8d5a7e03..36be2b1ea 100644 --- a/src/features/settings/components/SettingsView.test.tsx +++ b/src/features/settings/components/SettingsView.test.tsx @@ -113,6 +113,8 @@ const baseSettings: AppSettings = { notificationSoundsEnabled: true, systemNotificationsEnabled: true, subagentSystemNotificationsEnabled: true, + showSubagentSessions: true, + syncMode: "app_authoritative", splitChatDiffView: false, preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, @@ -670,6 +672,45 @@ describe("SettingsView Display", () => { ); }); }); + + it("toggles sub-agent session visibility in sidebar", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + renderDisplaySection({ + onUpdateAppSettings, + appSettings: { showSubagentSessions: false }, + }); + + const row = screen + .getByText("Show sub-agent sessions in sidebar") + .closest(".settings-toggle-row") as HTMLElement | null; + if (!row) { + throw new Error("Expected sub-agent session visibility row"); + } + fireEvent.click(within(row).getByRole("button")); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ showSubagentSessions: true }), + ); + }); + }); + + it("updates settings sync mode", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + renderDisplaySection({ + onUpdateAppSettings, + appSettings: { syncMode: "app_authoritative" }, + }); + + const select = screen.getByLabelText("Settings sync mode"); + fireEvent.change(select, { target: { value: "bidirectional" } }); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ syncMode: "bidirectional" }), + ); + }); + }); }); describe("SettingsView Environments", () => { diff --git a/src/features/settings/components/sections/SettingsDisplaySection.tsx b/src/features/settings/components/sections/SettingsDisplaySection.tsx index 72215d42b..96af5ac9d 100644 --- a/src/features/settings/components/sections/SettingsDisplaySection.tsx +++ b/src/features/settings/components/sections/SettingsDisplaySection.tsx @@ -592,6 +592,50 @@ export function SettingsDisplaySection({ +
+
+
Show sub-agent sessions in sidebar
+
+ Show or hide spawned sub-agent sessions beneath their parent thread. +
+
+ +
+
+ + +
+ App authoritative keeps CodexMonitor as the source of truth. Bidirectional preserves + direct file edits when possible and syncs changes both ways. +
+