From 56dd9dc76b2594a6894eeb9c779530ea0520ebd3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:32:10 +0800 Subject: [PATCH] fix(desktop): reuse machine services and original steward entry Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/desktop/loopx-control-plane/README.md | 51 ++++++++++----- .../loopx-control-plane/src-tauri/src/lib.rs | 6 +- .../src-tauri/src/service_endpoints.rs | 49 ++++++++++++++- .../src-tauri/src/services.rs | 35 +++++++++-- .../src-tauri/src/services_tests.rs | 34 +++++++++- .../personal-workspace/app-steward-entry.tsx | 58 +++++++++++------- .../personal-workspace-model.ts | 2 + .../personal-workspace-page.tsx | 41 +++++++++---- .../private-conversation-panel.tsx | 2 +- .../dashboard/src/views/dashboard-page.tsx | 20 ++++-- .../app-conversation-and-async-inbox-v0.md | 21 ++++++- ...p-conversation-and-async-inbox-v0.zh-CN.md | 14 ++++- .../after-original-narrow.png | Bin 0 -> 51777 bytes .../app-steward-overview/after-original.png | Bin 0 -> 83620 bytes .../app-steward-overview/before-ordinary.png | Bin 0 -> 105687 bytes .../unavailable-narrow.png | Bin 0 -> 56560 bytes 16 files changed, 260 insertions(+), 73 deletions(-) create mode 100644 docs/assets/personal-workspace/app-steward-overview/after-original-narrow.png create mode 100644 docs/assets/personal-workspace/app-steward-overview/after-original.png create mode 100644 docs/assets/personal-workspace/app-steward-overview/before-ordinary.png create mode 100644 docs/assets/personal-workspace/app-steward-overview/unavailable-narrow.png diff --git a/apps/desktop/loopx-control-plane/README.md b/apps/desktop/loopx-control-plane/README.md index b84976f709..a3bf74c146 100644 --- a/apps/desktop/loopx-control-plane/README.md +++ b/apps/desktop/loopx-control-plane/README.md @@ -419,23 +419,40 @@ restricted to loopback CORS and the existing preview/apply authority boundary. ## Coexistence With `loopx dashboard` -The packaged App allocates its own private Status and Chat endpoints for each -window. `loopx dashboard` keeps its CLI endpoints on `8766` and `8767`; either -entrypoint can run without adopting or stopping the other's listeners. Vite -development retains those CLI ports and its qualified-service reuse path. - -The packaged App watches its own direct service children. When one exits, it -restarts that service on the same private endpoint with the same qualified -runtime and identity, keeping the healthy peer running. Recovery attempts are -at least two seconds apart. An occupied endpoint fails closed and is retried; -the intervening listener is never adopted or stopped. A live but unresponsive -child is outside this exit-recovery path. - -Recovery keeps an update check's error or available-update choice visible. It -waits during maintenance and after an App replacement requires restart or leaves -the App incomplete. The native shell sends no conversation input or replay; -persisted Session and Turn recovery remain owned by Core. Closing the App stops -only its own service process groups. +The normal packaged App and `loopx dashboard` use the same machine Status and +Chat endpoints on `8766` and `8767`. The supervisor first qualifies the existing +LoopX listener and its exact runtime identity. It reuses a matching service and +keeps a loaded standard LaunchAgent as its owner; opening a window does not +create another Chat store or external-message consumer. A foreign or +unqualified listener is an error, never another port or an empty history. +A declared but unloaded machine LaunchAgent stays authoritative: the App reports +recovery instead of starting another service with an empty default store. +Core retains original binding, Session, Turn and delegation audience semantics; +sharing the service does not merge private conversations or widen permissions. + +Closing a window never stops a borrowed service. Services the App itself starts +still retain the existing owned-process lifecycle; installing a persistent +machine service on a fresh machine is a separate lifecycle qualification. +A configured machine service must retain its original registry and Chat runtime, +rather than replacing them with an empty default store during adoption. + +Developer/QA runs may explicitly set `LOOPX_DESKTOP_PRIVATE_ENDPOINTS=1` to use +the existing private-endpoint path (only `0` and `1` are accepted). Private ports +do not isolate stored data: QA must also pin a disposable registry/runtime through +the existing `LOOPX_BIN` test wrapper. In either mode the supervisor watches and +restarts only its own direct children on the same ports, with the same qualified +runtime and at least two seconds between recovery attempts. An occupied private endpoint +is never borrowed or stopped. This isolation is not the ordinary product path. + +Both paths retain update errors, maintenance exclusion, existing preview/apply +authority and Core-owned Session recovery. The native shell sends no conversation +input and never replays a Turn to establish service readiness. + +普通 App 与网页默认连接同一机器服务;打开窗口不再默认创建另一份 Chat 存储或 +外部消息消费者。复用前核验服务及准确运行时身份;原 registry、Chat runtime、 +Session 与委派受众保持不变,不混合不同私聊或扩大权限。借用的常驻服务不随窗口 +关闭而停止。App 自己启动的服务保留既有生命周期,首次安装常驻服务仍单独验收。 +开发隔离须明确设置上述环境变量;服务不可用或来源未核验时显示恢复,不切到空数据。 ## Prerequisites diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs index 574507639f..eb83f60d7f 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/lib.rs @@ -125,7 +125,11 @@ pub fn run() { // Release builds load the versioned LoopX Chat workspace that ships inside // the installed `loopx` release, so `loopx update` refreshes the frontend // and backend together instead of reusing a separately built asset bundle. - let endpoints = service_endpoints::ServiceEndpoints::allocate(!cfg!(dev)) + // App and browser are clients of the same machine services. Private + // endpoints are an explicit developer/QA choice, never a second default + // Chat store created merely by opening a release window. + let private_endpoints = std::env::var_os("LOOPX_DESKTOP_PRIVATE_ENDPOINTS"); + let endpoints = service_endpoints::ServiceEndpoints::for_window(private_endpoints.as_deref()) .expect("could not allocate LoopX loopback endpoints"); #[cfg(dev)] let web_origin = "http://127.0.0.1:5173".to_string(); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs index 3f4f797bcf..a6546b764c 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/service_endpoints.rs @@ -1,9 +1,9 @@ -use std::net::TcpListener; +use std::{ffi::OsStr, net::TcpListener}; use crate::services::ServiceKind; -/// A native window keeps one endpoint pair across runtime repairs. Release -/// windows own their services; Vite retains the CLI ports its proxy expects. +/// A native window keeps one endpoint pair across runtime repairs. Normal +/// windows share the machine services; explicit private endpoints are opt-in. #[derive(Clone, Copy)] pub(crate) struct ServiceEndpoints { status: u16, @@ -12,6 +12,21 @@ pub(crate) struct ServiceEndpoints { } impl ServiceEndpoints { + pub fn for_window(private_endpoints: Option<&OsStr>) -> std::io::Result { + let isolated = match private_endpoints { + None => false, + Some(value) if value == "0" => false, + Some(value) if value == "1" => true, + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "LOOPX_DESKTOP_PRIVATE_ENDPOINTS must be 0 or 1", + )) + } + }; + Self::allocate(isolated) + } + pub fn allocate(isolated: bool) -> std::io::Result { if !isolated { return Ok(Self { @@ -62,6 +77,34 @@ fn reserve_private_endpoint() -> std::io::Result { mod tests { use super::*; + #[test] + fn ordinary_window_uses_the_same_machine_service_addresses() { + for choice in [None, Some(OsStr::new("0"))] { + let endpoints = ServiceEndpoints::for_window(choice).unwrap(); + assert_eq!(endpoints.port(ServiceKind::Status), 8766); + assert_eq!(endpoints.port(ServiceKind::Chat), 8767); + assert!(!endpoints.isolated); + } + assert!( + ServiceEndpoints::for_window(Some(OsStr::new("1"))) + .unwrap() + .isolated + ); + } + + #[test] + fn invalid_private_endpoint_choice_cannot_fall_through_to_machine_state() { + for choice in ["", "true", "yes", "2"] { + assert_eq!( + ServiceEndpoints::for_window(Some(OsStr::new(choice))) + .err() + .unwrap() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + } + #[test] fn vite_keeps_its_proxy_endpoints() { let endpoints = ServiceEndpoints::allocate(false).unwrap(); diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs index ee5118b4f7..7e16c5ced2 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -207,17 +207,16 @@ impl ServiceSet { } } - /// Recover only this window's exited private children. Keep its qualified + /// Recover only this window's exited owned children. Keep its qualified /// artifact, ports and healthy peer; never infer ownership from a listener. pub(crate) fn recover_exited( &mut self, endpoints: &ServiceEndpoints, now: Instant, ) -> Result { - if !endpoints.isolated - || self - .last_recovery_attempt - .is_some_and(|at| now.duration_since(at) < Duration::from_secs(2)) + if self + .last_recovery_attempt + .is_some_and(|at| now.duration_since(at) < Duration::from_secs(2)) { return Ok(false); } @@ -353,7 +352,7 @@ fn connect( } } -// Release windows never borrow a listener or restart a LaunchAgent. A matching +// Explicit private endpoints never borrow a listener or restart a LaunchAgent. A matching // revision alone cannot establish the listener's registry/process ownership. fn connect_owned_service( kind: ServiceKind, @@ -508,6 +507,16 @@ fn connect_service( ))); } + // A declared machine service owns its registry and Chat store even while + // launchd has it unloaded. Do not replace that source with an empty default. + #[cfg(target_os = "macos")] + if platform_managed_service_declared(env::var_os("HOME").as_deref(), kind) { + return Err(ServiceError(format!( + "system-managed LoopX {} is configured but not loaded; restore its service and retry", + kind.label() + ))); + } + let mut command = Command::new(executable); configure_runtime_environment(&mut command); if matches!(kind, ServiceKind::Chat) { @@ -590,6 +599,20 @@ fn request_platform_managed_start(kind: ServiceKind) -> bool { true } +#[cfg(target_os = "macos")] +fn platform_managed_service_declared(home: Option<&OsStr>, kind: ServiceKind) -> bool { + let Some(home) = home else { + return false; + }; + let path = PathBuf::from(home) + .join("Library/LaunchAgents") + .join(format!("{}.plist", platform_managed_service_label(kind))); + match std::fs::symlink_metadata(path) { + Ok(_) => true, + Err(error) => error.kind() != std::io::ErrorKind::NotFound, + } +} + fn platform_managed_service_label(kind: ServiceKind) -> &'static str { match kind { ServiceKind::Status => "com.loopx.status", diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs index 869c6d67a0..1d0b5d9121 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs @@ -670,7 +670,7 @@ fn exit_private_service(root: &Path, kind: ServiceKind, services: &mut ServiceSe #[cfg(unix)] #[test] -fn exited_private_children_recover_individually_with_the_frozen_identity() { +fn exited_owned_children_recover_individually_with_the_frozen_identity() { let _fixture = PRIVATE_RECOVERY_FIXTURE.lock().unwrap(); let (root, runtime, endpoints) = private_recovery_fixture(); let mut services = ServiceSet::start(&runtime, &endpoints, |_| {}).unwrap(); @@ -689,10 +689,12 @@ fn exited_private_children_recover_individually_with_the_frozen_identity() { services.last_recovery_attempt = None; let mut development = endpoints; development.isolated = false; - assert!(!services + // Ownership, rather than private/shared mode, authorizes recovery. + // Keep ephemeral fixture ports so this cannot touch machine services. + assert!(services .recover_exited(&development, Instant::now()) .unwrap()); - assert!(services.recover_exited(&endpoints, Instant::now()).unwrap()); + assert!(!services.recover_exited(&endpoints, Instant::now()).unwrap()); assert_ne!( services .owned @@ -837,3 +839,29 @@ fn private_recovery_cannot_start_an_unowned_or_development_service() { .recover_exited(&ServiceEndpoints::allocate(false).unwrap(), Instant::now()) .unwrap()); } + +#[cfg(target_os = "macos")] +#[test] +fn declared_machine_source_remains_authoritative_while_unloaded() { + let root = tempfile::tempdir().unwrap(); + assert!(!platform_managed_service_declared( + Some(root.path().as_os_str()), + ServiceKind::Chat + )); + let launch_agents = root.path().join("Library/LaunchAgents"); + fs::create_dir_all(&launch_agents).unwrap(); + // Even a damaged declaration must not admit a different empty Chat store. + fs::write( + launch_agents.join("com.loopx.chat.plist"), + b"damaged declaration", + ) + .unwrap(); + assert!(platform_managed_service_declared( + Some(root.path().as_os_str()), + ServiceKind::Chat + )); + assert!(!platform_managed_service_declared( + Some(root.path().as_os_str()), + ServiceKind::Status + )); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx b/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx index 89cc014020..efdc57bd1e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/app-steward-entry.tsx @@ -3,44 +3,58 @@ import {Button} from "../../components/ui/button"; import {fetchAppStewardTarget, type BoundConversationTarget} from "../../data/chat"; import {useWorkspaceI18n} from "./i18n"; -/** Only mount at the local, unbound manager Chat entry. Children must not mount - * until discovery finishes: they otherwise expose the ordinary manager queue. - * Once opened, the route freezes the exact Session and this boundary unmounts. +/** One read-only resolver for both App entrypoints. Pending/failed discovery + * never exposes the ordinary manager queue. Explicit bound routes bypass it. */ -export function AppStewardEntry({children, onOpen}: {children: ReactNode; onOpen: (target: BoundConversationTarget) => void}) { - const {locale} = useWorkspaceI18n(); - const zh = locale === "zh-CN"; - const [state, setState] = useState<"loading" | "ordinary" | "unavailable">("loading"); - const [revision, setRevision] = useState(0); - const open = useRef(onOpen); - open.current = onOpen; +export function useAppStewardTarget(enabled: boolean) { + const [read, setRead] = useState<{enabled: boolean; revision: number; ready: boolean; + target: BoundConversationTarget | null; unavailable: boolean}>( + {enabled, revision: 0, ready: false, target: null, unavailable: false}); + // Invalidate in the render that changes scope, before a composer can submit + // using a previous ordinary result. React retries this component immediately. + if (read.enabled !== enabled) setRead({...read, enabled, ready: false, target: null, unavailable: false}); + const retry = () => setRead(value => ({...value, revision: value.revision + 1, ready: false, target: null, unavailable: false})); useEffect(() => { - const changed = () => {setState("loading"); setRevision(value => value + 1);}; - window.addEventListener("loopx:app-steward-changed", changed); - return () => window.removeEventListener("loopx:app-steward-changed", changed); + window.addEventListener("loopx:app-steward-changed", retry); + return () => window.removeEventListener("loopx:app-steward-changed", retry); }, []); + const revision = read.revision; useEffect(() => { + if (!enabled) return; let active = true; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - void fetchAppStewardTarget(controller.signal).then(target => { - if (!active) return; - if (target) open.current(target); - else setState("ordinary"); - }).catch(() => {if (active) setState("unavailable");}).finally(() => clearTimeout(timeout)); + const settle = (target: BoundConversationTarget | null, unavailable: boolean) => { + if (active) setRead(value => value.enabled === enabled && value.revision === revision + ? {...value, ready: true, target, unavailable} : value); + }; + void fetchAppStewardTarget(controller.signal).then(target => settle(target, false)) + .catch(() => settle(null, true)).finally(() => clearTimeout(timeout)); return () => {active = false; controller.abort(); clearTimeout(timeout);}; - }, [revision]); - if (state === "ordinary") return children; + }, [enabled, revision]); + const ready = enabled && read.enabled === enabled && read.ready; + return {target: ready ? read.target : null, loading: enabled && !ready, + unavailable: ready && read.unavailable, retry}; +} + +export function AppStewardEntry({children, onOpen}: {children: ReactNode; onOpen: (target: BoundConversationTarget) => void}) { + const {locale} = useWorkspaceI18n(); + const zh = locale === "zh-CN"; + const entry = useAppStewardTarget(true); + const open = useRef(onOpen); + open.current = onOpen; + useEffect(() => {if (entry.target) open.current(entry.target);}, [entry.target]); + if (!entry.loading && !entry.unavailable && !entry.target) return children; const settings = new URL(window.location.href); settings.searchParams.set("view", "overview"); settings.searchParams.set("settings_scope", "machine"); settings.searchParams.set("settings_tab", "lark"); return

{zh ? "LoopX 管家" : "LoopX steward"}

- {state === "loading" ?

{zh ? "正在打开管家对话…" : "Opening the steward conversation…"}

: <> + {!entry.unavailable ?

{zh ? "正在打开管家对话…" : "Opening the steward conversation…"}

: <>

{zh ? "原管家会话暂不可用。请检查所选连接后重试。" : "The original steward conversation is unavailable. Check the selected connection and retry."}

- + {zh ? "打开会话设置" : "Open conversation settings"}
} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 501853021c..f13a1af909 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -114,6 +114,8 @@ export type WorkspaceConversationDirectory = { /** null returns the conversation to the steward scope. */ onSelect: (projectRef: string | null) => void; onOpenOriginal?: (target: BoundConversationTarget) => void; + /** Local manager entry discovery blocks sending, never Goal navigation. */ + entryGate?: {unavailable: boolean; retry: () => void}; bound?: { target: BoundConversationTarget; session?: ChatSessionSummary; source?: PrivateConversation; error?: string; retry: () => void }; managed?: {target: import("../../data/chat").ManagedConversationTarget; session?: import("../../data/chat").ChatSessionSummary; error?: string; retry: () => void}; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index b146564bf0..cb507b84ba 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -419,6 +419,9 @@ function ManagerConversationTray({ const latestUserIndex = messages.reduce((latest, message, index) => message.role === "user" ? index : latest, 0); const latestExchange = messages.slice(Math.max(0, latestUserIndex)); const visibleMessages = latestExchange.slice(-3); + const activeIndex = messages.reduce((latest, message, index) => message.role === "assistant" && message.pending ? index : latest, -1); + // A queued follow-up must not hide the current Turn's activity and controls. + if (activeIndex >= 0 && activeIndex < latestUserIndex) visibleMessages.unshift(messages[activeIndex]); const latestAssistantMessage = visibleMessages.filter((item) => item.role === "assistant" && !item.pending).at(-1); useEffect(() => { @@ -436,7 +439,7 @@ function ManagerConversationTray({ {title ?? t("conversation.title")} - {messages.at(-1)?.pending ? t("conversation.replying") : t("common.recently")} + {visibleMessages.some(message => message.pending) ? t("conversation.replying") : t("common.recently")}
{onDraftTask && latestAssistantMessage ? ( @@ -1031,10 +1034,14 @@ export function PersonalWorkspacePage({ ?? (locale === "zh-CN" ? "原会话" : "Original conversation"); const selectedWorkspaceProject = workspaceConversations?.projects?.find((project) => project.project_ref === selectedWorkspaceRef) ?? null; // A workspace scope exists only inside the steward conversation tab. - const managerChatOpen = originalConversation !== undefined || selectedWorkspaceRef !== null || selectedGoalTab === "chat"; + const managerChatOpen = selectedWorkspaceRef !== null || selectedGoalTab === "chat"; const workspaceUnavailable = selectedWorkspaceRef !== null && workspaceConversations?.projects != null && !selectedWorkspaceProject; const workspaceTitle = selectedWorkspaceRef ? selectedWorkspaceProject?.title ?? t("workspace.unknownTitle") : null; function setSelectedGoalTab(view: WorkspaceGoalTab) { + if (view === "chat" && originalConversation && workspaceConversations?.onOpenOriginal) { + workspaceConversations.onOpenOriginal(originalConversation.target); + return; + } setLocalView(view); callbacks.onSelectView?.(view); } @@ -1346,8 +1353,8 @@ export function PersonalWorkspacePage({ const retainedSteering = readSteeringRequest(composerDraftKey); const retryingSteering = retainedSteering && retainedSteering.sessionId === conversationSessionId && retainedSteering.text.trim() === composer.trim() ? retainedSteering : undefined; - const composerBlocked = steering || (!(retryingSteering || queueTurnId) && (sending || conversationTurnRunning)); - const quickPromptBlocked = steering || sending || conversationTurnRunning; + const composerBlocked = Boolean(workspaceConversations?.entryGate) || steering || (!(retryingSteering || queueTurnId) && (sending || conversationTurnRunning)); + const quickPromptBlocked = Boolean(workspaceConversations?.entryGate) || Boolean(originalConversation && !originalConversation.session?.resumable) || steering || sending || conversationTurnRunning; // The Goal overview is for reading; its composer folds to one line until asked for. const [overviewComposerOpen, setOverviewComposerOpen] = useState(false); useEffect(() => setOverviewComposerOpen(false), [selectedGoalId, selectedGoalTab]); @@ -2626,7 +2633,7 @@ export function PersonalWorkspacePage({ } }, [attentionKeySignature, attentionNotificationsActive, attentionNotificationSupportState, t]); - const composerSuggestions = !originalConversation && !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{ + const composerSuggestions = (!originalConversation || !managerChatOpen) && !selectedWorkspaceRef && (!conversationOpen || !conversationMessages.length) ?
{ if (event.key !== "Escape") return; event.currentTarget.open = false; event.currentTarget.querySelector("summary")?.focus(); @@ -2737,15 +2744,15 @@ export function PersonalWorkspacePage({
: undefined} - originalConversationTitle={originalConversation ? `${originalConversation.source?.app_ref ?? "Lark"} · ${originalTitle}` : undefined} + originalConversationTitle={workspaceConversations?.entryGate ? (locale === "zh-CN" ? "管家来源未核验" : "Steward source unverified") : originalConversation ? `${originalConversation.source?.app_ref ?? "Lark"} · ${originalTitle}` : undefined} agents={agents} scopeUnavailable={rootUnavailable} agentWorkActive={agentWorkActive} - conversationScope={!originalConversation && workspaceConversations && (workspaceConversations.projects?.length || workspaceConversations.readFailed || selectedWorkspaceRef) ? { + conversationScope={!workspaceConversations?.entryGate && !originalConversation && workspaceConversations && (workspaceConversations.projects?.length || workspaceConversations.readFailed || selectedWorkspaceRef) ? { onChange: (value) => { setActiveSessionRun(null); workspaceConversations.onSelect(value === stewardScopeValue ? null : value); @@ -2920,6 +2927,15 @@ export function PersonalWorkspacePage({ : null}
{!rootUnavailable ?