From e6c90bb7c430d1b2af16508b634f9a5283b7fa3b Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 26 Jul 2026 21:20:26 +0100 Subject: [PATCH 01/71] Polish community rail and mobile pairing (#2972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - align the multi-community rail with the content surface and balance its visible 10px side gutters - center Mobile pairing, start sessions on demand, and keep retry states inside the QR area - reveal the QR code and copy action with 250ms motion and use the standard loading spinner The rail was centered within its own box, but the adjacent sidebar added another 11px to the visible right gap. Mobile pairing also started before user intent, which could leave an idle session waiting for EOSE. ## Validation - `pnpm -C desktop build:e2e` - `pnpm -C desktop test` — 3,516 passed - `pnpm -C desktop exec playwright test tests/e2e/community-rail.spec.ts --project=smoke` — 19 passed - `pnpm -C desktop exec playwright test tests/e2e/mobile-pairing-qr.spec.ts --project=smoke` — 1 passed `pnpm -C desktop check` is currently blocked by the existing `src-tauri/src/managed_agents/runtime.rs` file-size baseline (2,220 lines; limit 2,216). --- desktop/src-tauri/src/commands/pairing.rs | 128 ++++- desktop/src/app/RelayConnectionOverlay.tsx | 4 +- .../settings/ui/MobilePairingCard.tsx | 497 +++++++++++------- .../src/features/sidebar/ui/AppSidebar.tsx | 4 +- .../src/features/sidebar/ui/CommunityRail.tsx | 14 +- desktop/src/shared/api/relayClientSession.ts | 69 ++- desktop/src/shared/api/relayClientShared.ts | 13 +- .../shared/api/relayClosedRecovery.test.mjs | 37 +- desktop/src/shared/api/relayClosedRecovery.ts | 11 +- desktop/src/shared/api/relayGateBoundary.ts | 76 +++ desktop/src/shared/api/relayMembers.ts | 4 +- .../src/shared/styles/globals/animations.css | 63 +++ desktop/src/shared/ui/styled-qr-code.test.mjs | 15 + desktop/src/shared/ui/styled-qr-code.tsx | 25 +- desktop/src/testing/e2eBridge.ts | 21 +- desktop/tests/e2e/community-rail.spec.ts | 32 +- desktop/tests/e2e/mobile-pairing-qr.spec.ts | 175 +++++- desktop/tests/helpers/bridge.ts | 4 + 18 files changed, 885 insertions(+), 307 deletions(-) diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 639ae16da3d..fc874a01500 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -35,6 +36,7 @@ struct PairingErrorPayload { /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, + generation: Arc, cancel: std::sync::Mutex>, /// Send JSON-serialized events to the background WS task for relay publication. outbound_tx: std::sync::Mutex>>, @@ -47,6 +49,7 @@ impl PairingHandle { pub fn new() -> Self { Self { session: Arc::new(tokio::sync::Mutex::new(None)), + generation: Arc::new(AtomicU64::new(0)), cancel: std::sync::Mutex::new(None), outbound_tx: std::sync::Mutex::new(None), payload: std::sync::Mutex::new(None), @@ -71,10 +74,18 @@ pub async fn start_pairing( state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { + let task_generation = pairing + .generation + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1); if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } pairing.clear(); + { + let mut session = pairing.session.lock().await; + *session = None; + } let keys = state.signing_keys()?; let nsec = keys @@ -117,9 +128,12 @@ pub async fn start_pairing( *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); let session_arc = Arc::clone(&pairing.session); + let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( pairing_relay_url, session_arc, + generation, + task_generation, cancel, outbound_rx, app, @@ -199,6 +213,8 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } + pairing.generation.fetch_add(1, Ordering::SeqCst); + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -215,22 +231,35 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str async fn pairing_ws_task( relay_url: String, session: Arc>>, + generation: Arc, + task_generation: u64, cancel: CancellationToken, mut outbound_rx: mpsc::Receiver, app: AppHandle, ) { - if let Err(e) = - pairing_ws_task_inner(&relay_url, &session, &cancel, &mut outbound_rx, &app).await + if let Err(e) = pairing_ws_task_inner( + &relay_url, + &session, + &generation, + task_generation, + &cancel, + &mut outbound_rx, + &app, + ) + .await { - let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + if pairing_task_is_current(&generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + } } - let mut s = session.lock().await; - *s = None; + clear_pairing_session_if_current(&session, &generation, task_generation).await; } async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, cancel: &CancellationToken, outbound_rx: &mut mpsc::Receiver, app: &AppHandle, @@ -261,12 +290,18 @@ async fn pairing_ws_task_inner( tokio::pin!(hard_timeout); loop { + if !pairing_task_is_current(generation, task_generation) { + break; + } + tokio::select! { _ = cancel.cancelled() => break, _ = &mut hard_timeout => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Session timed out".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Session timed out".into(), + }); + } break; } Some(json_msg) = outbound_rx.recv() => { @@ -282,30 +317,42 @@ async fn pairing_ws_task_inner( let Message::Text(text) = msg else { continue }; if let Some(event) = parse_relay_event(text.as_str(), "pair") { + if !pairing_task_is_current(generation, task_generation) { + break; + } + let mut guard = session.lock().await; let Some(s) = guard.as_mut() else { break }; if let Ok(reason) = s.handle_abort(&event) { - let _ = app.emit("pairing-aborted", PairingAbortedPayload { - reason: format!("{reason:?}"), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-aborted", PairingAbortedPayload { + reason: format!("{reason:?}"), + }); + } break; } if let Ok(sas) = s.handle_offer(&event) { - let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + } continue; } match s.handle_complete(&event) { Ok(()) => { - let _ = app.emit("pairing-complete", serde_json::json!({})); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } break; } Err(ref e) if format!("{e}").contains("success=false") => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Mobile device reported failure importing credentials".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + } break; } Err(_) => {} @@ -318,6 +365,21 @@ async fn pairing_ws_task_inner( Ok(()) } +fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { + generation.load(Ordering::SeqCst) == task_generation +} + +async fn clear_pairing_session_if_current( + session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, +) { + let mut session = session.lock().await; + if pairing_task_is_current(generation, task_generation) { + *session = None; + } +} + async fn handle_nip42_auth( read: &mut R, write: &mut W, @@ -527,6 +589,40 @@ where .map_err(|_| "timeout waiting for EOSE".to_string())? } +#[cfg(test)] +mod pairing_generation_tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + use super::{clear_pairing_session_if_current, PairingSession}; + + #[tokio::test] + async fn stale_task_does_not_clear_replacement_session() { + let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); + let generation = AtomicU64::new(1); + + generation.store(2, Ordering::SeqCst); + let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); + *session.lock().await = Some(replacement); + + clear_pairing_session_if_current(&session, &generation, 1).await; + + assert!(session.lock().await.is_some()); + } + + #[tokio::test] + async fn current_task_clears_its_session() { + let (active, _) = PairingSession::new_source("ws://active.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(active))); + let generation = AtomicU64::new(3); + + clear_pairing_session_if_current(&session, &generation, 3).await; + + assert!(session.lock().await.is_none()); + } +} + #[cfg(test)] mod pairing_relay_tests { use super::{ diff --git a/desktop/src/app/RelayConnectionOverlay.tsx b/desktop/src/app/RelayConnectionOverlay.tsx index faf28ee51e0..07c6933d048 100644 --- a/desktop/src/app/RelayConnectionOverlay.tsx +++ b/desktop/src/app/RelayConnectionOverlay.tsx @@ -60,7 +60,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", @@ -86,7 +86,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 2013d17de35..e15f54316b1 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -2,16 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy, + LoaderCircle, + RefreshCw, ShieldCheck, - Smartphone, TriangleAlert, X, } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; -import { Spinner } from "@/shared/ui/spinner"; - import { cancelPairing, confirmPairingSas, @@ -31,70 +30,201 @@ import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; type PairingStep = + | "idle" | "generating" | "qr" + | "expired" | "sas" | "transferring" | "done" | "error"; -function PairingDialog({ - open, - onOpenChange, +function pairingErrorMessage(error: unknown) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : ""; + + if (message.toLowerCase().includes("timeout waiting for eose")) { + return "Pairing took too long. Try again."; + } + + return message || "We couldn't start pairing. Try again."; +} + +function isPairingSessionTimeout(message: string) { + return message.toLowerCase().includes("session timed out"); +} + +function PairingStatusDialog({ + onClose, + onConfirm, + onDeny, + sasCode, + step, +}: { + onClose: () => void; + onConfirm: () => void; + onDeny: () => void; + sasCode: string | null; + step: PairingStep; +}) { + const open = step === "sas" || step === "transferring" || step === "done"; + + return ( + { + if (!nextOpen) onClose(); + }} + open={open} + > + +
+ + Pair mobile device + + {step === "sas" + ? "Verify the security code matches your mobile device." + : step === "done" + ? "Your mobile device is now paired." + : "Securely sending your identity to the mobile app."} + + + +
+ {step === "sas" && sasCode ? ( +
+
+ +

+ Verify this code matches your mobile device +

+
+

+ {sasCode.slice(0, 3)} {sasCode.slice(3)} +

+
+

+ You are about to transfer your Buzz identity to another + device. Only confirm if you initiated this pairing. +

+
+ +
+ + +
+
+ ) : step === "transferring" ? ( +
+
+ ) : step === "done" ? ( +
+
+ +
+

Mobile device paired

+

+ Your mobile app is now connected to this relay. +

+
+ ) : null} +
+
+
+
+ ); +} + +export function MobilePairingCard({ + currentPubkey, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + currentPubkey?: string; }) { - const [step, setStep] = useState("generating"); + const [step, setStep] = useState("idle"); const [qrUri, setQrUri] = useState(null); const [sasCode, setSasCode] = useState(null); const [error, setError] = useState(null); + const requestIdRef = useRef(0); + const pairingActiveRef = useRef(false); const stepRef = useRef(step); stepRef.current = step; - // Start pairing when dialog opens. - useEffect(() => { - if (!open) return; - + const beginPairing = useCallback(() => { + const requestId = ++requestIdRef.current; + pairingActiveRef.current = true; setStep("generating"); setQrUri(null); setSasCode(null); setError(null); - let cancelled = false; startPairing().then( (uri) => { - if (!cancelled) { + if (requestId === requestIdRef.current) { setQrUri(uri); setStep("qr"); } }, (err) => { - if (!cancelled) { - setError( - err instanceof Error - ? err.message - : "Failed to start pairing session", - ); + if (requestId === requestIdRef.current) { + pairingActiveRef.current = false; + setError(pairingErrorMessage(err)); setStep("error"); } }, ); + }, []); - return () => { - cancelled = true; - }; - }, [open]); - - // Listen for Tauri events from the pairing backend. useEffect(() => { - if (!open) return; + ++requestIdRef.current; + pairingActiveRef.current = false; + setStep("idle"); + setQrUri(null); + setSasCode(null); + setError(null); + + if (!currentPubkey) { + return; + } let cancelled = false; const unlisteners: (() => void)[] = []; listen<{ sas: string }>("pairing-sas-received", (event) => { - if (!cancelled) { + if (!cancelled && pairingActiveRef.current) { setSasCode(event.payload.sas); setStep("sas"); } @@ -104,7 +234,8 @@ function PairingDialog({ }); listen("pairing-complete", () => { - if (!cancelled) { + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; setStep("done"); } }).then((fn) => { @@ -113,8 +244,9 @@ function PairingDialog({ }); listen<{ reason: string }>("pairing-aborted", (event) => { - if (!cancelled) { - setError(`Pairing aborted: ${event.payload.reason}`); + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + setError(`Pairing stopped: ${event.payload.reason}`); setStep("error"); } }).then((fn) => { @@ -123,8 +255,17 @@ function PairingDialog({ }); listen<{ message: string }>("pairing-error", (event) => { - if (!cancelled) { - setError(event.payload.message); + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + if (isPairingSessionTimeout(event.payload.message)) { + setQrUri(null); + setSasCode(null); + setError(null); + setStep("expired"); + return; + } + + setError(pairingErrorMessage(event.payload.message)); setStep("error"); } }).then((fn) => { @@ -134,20 +275,20 @@ function PairingDialog({ return () => { cancelled = true; + ++requestIdRef.current; + pairingActiveRef.current = false; for (const fn of unlisteners) fn(); - }; - }, [open]); - - // Cancel pairing when dialog closes before completion. - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen && stepRef.current !== "done") { + if (stepRef.current !== "idle" && stepRef.current !== "done") { cancelPairing().catch(() => {}); } - onOpenChange(nextOpen); - }, - [onOpenChange], - ); + }; + }, [currentPubkey]); + + async function handleCopy() { + if (!qrUri) return; + await writeTextToClipboard(qrUri); + toast.success("Copied to clipboard"); + } async function handleConfirmSas() { setStep("transferring"); @@ -155,192 +296,150 @@ function PairingDialog({ await confirmPairingSas(); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to send credentials", + err instanceof Error + ? err.message + : "We couldn't send your identity. Try again.", ); + pairingActiveRef.current = false; setStep("error"); } } function handleDenySas() { + pairingActiveRef.current = false; cancelPairing().catch(() => {}); - setError("SAS code mismatch — pairing cancelled for security."); + setError("The codes didn't match. Pairing was canceled."); setStep("error"); } - async function handleCopy() { - if (!qrUri) return; - await writeTextToClipboard(qrUri); - toast.success("Copied to clipboard"); + function handleStatusDialogClose() { + pairingActiveRef.current = false; + if (stepRef.current === "done") { + setStep("idle"); + setQrUri(null); + setSasCode(null); + setError(null); + return; + } + + cancelPairing().catch(() => {}); + setError("Pairing was canceled."); + setStep("error"); } return ( - - -
- - Pair Mobile Device - - {step === "sas" - ? "Verify the security code matches your mobile device." - : step === "done" - ? "Your mobile device is now paired." - : "Scan this QR code with the Buzz mobile app to securely pair."} - - +
+ + Connect the Buzz mobile app to this relay by scanning a QR code. The + connection is secured with end-to-end encryption and a verification + code. + + } + /> -
- {step === "error" && error ? ( -
- - {error} -
- ) : step === "generating" ? ( -
- + + +
+ {step === "qr" && qrUri ? ( + + ) : step === "expired" ? ( +

- Preparing secure pairing session... + Pairing code expired.

-
- ) : step === "qr" && qrUri ? ( -
-
- -
-
- ) : step === "sas" && sasCode ? ( -
-
- -

- Verify this code matches your mobile device -

-
-

- {sasCode.slice(0, 3)} {sasCode.slice(3)} -

-
-

- You are about to transfer your Buzz identity to another - device. Only confirm if you initiated this pairing. -

-
- -
- - -
-
- ) : step === "transferring" ? ( -
- -

- Sending identity to mobile device... + ) : step === "error" ? ( +

+ +

+ {error ?? "Pairing session ended."}

+
- ) : step === "done" ? ( -
-
- -
-

- Mobile device paired successfully + ) : step === "idle" ? ( + currentPubkey ? ( + + ) : ( +

+ Sign in to generate a mobile pairing code.

-

- Your mobile app is now connected to this relay. + ) + ) : ( +

+
- ) : null} + )}
-
- -
- ); -} -export function MobilePairingCard({ - currentPubkey, -}: { - currentPubkey?: string; -}) { - const [dialogOpen, setDialogOpen] = useState(false); - - return ( -
- - Connect the Buzz mobile app to this relay by scanning a QR code. The - connection is secured with end-to-end encryption and a verification - code. - - } - /> - - - - -
-

Pair Mobile Device

-

- Securely transfer your identity via NIP-AB protocol -

-
- + {step === "qr" && qrUri ? ( + + ) : null}
- {currentPubkey && ( - - )} + void handleConfirmSas()} + onDeny={handleDenySas} + sasCode={sasCode} + step={step} + />
); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index b40f56b0422..55f467f2159 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -562,7 +562,9 @@ export function AppSidebar({ variant="sidebar" >
1 ? "md:-ml-[11px] md:w-[calc(100%+11px)]" : "" + }`} data-sidebar-background data-testid="app-sidebar-scroll-anchor" > diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 86d632398b3..b15e0bab712 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -37,8 +37,6 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { cn } from "@/shared/lib/cn"; import { getInitials } from "@/shared/lib/initials"; -import { isMacPlatform } from "@/shared/lib/platform"; -import { useIsFullscreen } from "@/shared/lib/useIsFullscreen"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; type CommunityRailProps = { @@ -315,7 +313,6 @@ export function CommunityRail({ activeCommunityId, ); const iconsByCommunity = useCommunityIcons(communities); - const isFullscreen = useIsFullscreen(); const { markAllChannelsRead, onOpenSettings } = useAppShell(); const myMembershipQuery = useMyRelayMembershipLookupQuery(); const activeRole = myMembershipQuery.data?.membership?.role; @@ -370,19 +367,10 @@ export function CommunityRail({ }); }; - // macOS traffic lights overlay the top-left, so start buttons below them (they hide in fullscreen). - const topPaddingClass = - isMacPlatform() && !isFullscreen - ? "pt-(--buzz-top-chrome-height,40px)" - : "pt-3"; - return (
); + case "missing_binary": { + // Missing-binary rows are purely informational — the user must install the + // binary or update their PATH. No in-app action can fix this. + return ( +
+ + + {requirement.command} + {" "} + not found in PATH — install it or check your PATH settings + +
+ ); + } case "cli_config_invalid": { // Config-invalid rows are purely informational — the user must edit an // external file. No Agent runtimes CTA (Buzz can't repair ~/.codex/config.toml) diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ebf6b1f4201..4c085a05338 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -3,6 +3,11 @@ import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import { decode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; +import { + mergeMockCustomHarnesses, + handleSaveCustomHarness, + handleDeleteCustomHarness, +} from "./e2eBridgeCustomHarnesses.ts"; import { relayClient } from "@/shared/api/relayClient"; import type { ConnectionState } from "@/shared/api/relayClientShared"; @@ -70,6 +75,8 @@ type MockManagedAgentSeed = { name: string; avatarUrl?: string | null; personaId?: string | null; + /** Harness/runtime id pin; `null` = inherit from persona (native default). */ + runtime?: string | null; status?: RawManagedAgent["status"]; channelNames?: string[]; channelIds?: string[]; @@ -175,6 +182,8 @@ type E2eConfig = { acpAuthMethods?: Record; acpAuthMethodsErrors?: Record; acpAuthMethodsError?: string; + /** When set, the `delete_custom_harness` mock command throws with this message. */ + deleteCustomHarnessError?: string; connectAcpRuntimeResult?: RawConnectAcpRuntimeResult; connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; @@ -705,6 +714,8 @@ type RawManagedAgent = { pubkey: string; name: string; persona_id: string | null; + /** Record-level harness/runtime pin (`null` when inheriting from the persona). */ + runtime: string | null; relay_url: string; acp_command: string; agent_command: string; @@ -1456,6 +1467,7 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent { pubkey: agent.pubkey, name: agent.name, persona_id: agent.persona_id, + runtime: agent.runtime ?? null, relay_url: agent.relay_url, acp_command: agent.acp_command, agent_command: agent.agent_command, @@ -1989,6 +2001,9 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { pubkey: seed.pubkey, name: seed.name, persona_id: seed.personaId ?? null, + // Native serde always emits this key (`null` when unpinned) — the bridge + // must mirror the wire shape, not omit the key. + runtime: seed.runtime ?? null, relay_url: DEFAULT_RELAY_WS_URL, acp_command: "buzz-acp", agent_command: "goose", @@ -6961,7 +6976,9 @@ async function handleDiscoverAcpRuntimes( } const configured = config?.mock?.acpRuntimesCatalog; if (configured) { - return configured.map(withMockRuntimeConfigMetadata); + return mergeMockCustomHarnesses( + configured.map(withMockRuntimeConfigMetadata), + ); } const defaultCatalog: RawAcpRuntimeCatalogEntry[] = [ { @@ -6980,6 +6997,7 @@ async function handleDiscoverAcpRuntimes( underlying_cli_path: null, node_required: false, auth_status: { status: "not_applicable" }, + source: "builtin", login_hint: undefined, }, { @@ -6999,6 +7017,7 @@ async function handleDiscoverAcpRuntimes( underlying_cli_path: "/usr/local/bin/claude", node_required: false, auth_status: { status: "unknown" }, + source: "builtin", login_hint: undefined, }, { @@ -7018,6 +7037,7 @@ async function handleDiscoverAcpRuntimes( underlying_cli_path: null, node_required: false, auth_status: { status: "unknown" }, + source: "builtin", login_hint: undefined, }, { @@ -7036,10 +7056,13 @@ async function handleDiscoverAcpRuntimes( underlying_cli_path: null, node_required: false, auth_status: { status: "not_applicable" }, + source: "builtin", login_hint: undefined, }, ]; - return defaultCatalog.map(withMockRuntimeConfigMetadata); + return mergeMockCustomHarnesses( + defaultCatalog.map(withMockRuntimeConfigMetadata), + ); } async function handleDiscoverAcpAuthMethods( @@ -7642,6 +7665,8 @@ async function handleCreateManagedAgent( pubkey, name, persona_id: args.input.personaId ?? null, + // Create never pins a harness id — the record inherits from the persona. + runtime: null, relay_url: args.input.relayUrl ?? DEFAULT_RELAY_WS_URL, acp_command: args.input.acpCommand ?? "buzz-acp", agent_command: agentCommand, @@ -10016,6 +10041,15 @@ export function maybeInstallE2eTauriMocks() { return activeConfig?.mock?.relayRequiresMembership ?? false; case "discover_acp_providers": return handleDiscoverAcpRuntimes(activeConfig); + case "save_custom_harness": + return handleSaveCustomHarness( + payload as Parameters[0], + ); + case "delete_custom_harness": + return handleDeleteCustomHarness( + payload as Parameters[0], + activeConfig, + ); case "discover_acp_auth_methods": return handleDiscoverAcpAuthMethods( payload as { runtimeId?: string }, diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs new file mode 100644 index 00000000000..0a0fb36a922 --- /dev/null +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.test.mjs @@ -0,0 +1,246 @@ +/** + * Unit tests for the e2eBridge custom harness handlers (C-10). + * + * Tests are imported directly from the extracted e2eBridgeCustomHarnesses.ts + * module — no Tauri mock or browser environment needed — to prove: + * + * 1. save returns a catalog entry with source "custom" and correct fields + * 2. definition_env is preserved through save (non-empty env) + * 3. empty env produces absent definition_env (mirrors Rust BTreeMap serialization) + * 4. same-ID edit replaces the existing entry in the store (no duplicates) + * 5. rename (originalId ≠ id) removes the old key and inserts the new one + * 6. delete removes the entry from the store + * 7. delete is idempotent (not-found does not throw) + * 8. discover integration: saved harness appears in the discover result set + * alongside the default catalog (verifies the Map is shared by reference) + */ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + mockCustomHarnesses, + mergeMockCustomHarnesses, + resetMockCustomHarnesses, + handleSaveCustomHarness, + handleDeleteCustomHarness, +} from "./e2eBridgeCustomHarnesses.ts"; + +function makeArgs(overrides = {}) { + return { + definition: { + id: overrides.id ?? "test-harness", + label: overrides.label ?? "Test Harness", + command: overrides.command ?? "test-bin", + args: overrides.args ?? [], + env: overrides.env ?? {}, + installInstructionsUrl: overrides.installInstructionsUrl ?? "", + installHint: overrides.installHint ?? "", + }, + originalId: overrides.originalId ?? null, + }; +} + +// Reset the store before every test so tests are independent. +beforeEach(() => resetMockCustomHarnesses()); + +// ── save_custom_harness ─────────────────────────────────────────────────────── + +describe("handleSaveCustomHarness", () => { + it("returns a catalog entry with source 'custom'", () => { + const entry = handleSaveCustomHarness( + makeArgs({ id: "my-rt", label: "My RT" }), + ); + assert.equal(entry.id, "my-rt"); + assert.equal(entry.label, "My RT"); + assert.equal(entry.source, "custom"); + }); + + it("stores the entry in mockCustomHarnesses", () => { + handleSaveCustomHarness(makeArgs({ id: "stored" })); + assert.ok( + mockCustomHarnesses.has("stored"), + "store must contain the saved id", + ); + }); + + it("preserves non-empty definition_env", () => { + const env = { ANTHROPIC_API_KEY: "sk-test", MODEL: "claude-3" }; + const entry = handleSaveCustomHarness(makeArgs({ id: "env-rt", env })); + assert.deepStrictEqual(entry.definition_env, env); + assert.deepStrictEqual( + mockCustomHarnesses.get("env-rt")?.definition_env, + env, + ); + }); + + it("produces absent definition_env for empty env (mirrors Rust BTreeMap skip)", () => { + const entry = handleSaveCustomHarness(makeArgs({ id: "no-env", env: {} })); + assert.ok( + entry.definition_env === undefined || entry.definition_env === null, + "empty env must yield absent definition_env", + ); + }); + + it("same-ID edit replaces entry — no duplicates in the store", () => { + handleSaveCustomHarness(makeArgs({ id: "dup", label: "V1" })); + handleSaveCustomHarness( + makeArgs({ id: "dup", label: "V2", originalId: "dup" }), + ); + assert.equal( + mockCustomHarnesses.size, + 1, + "same-ID edit must not duplicate store entries", + ); + assert.equal(mockCustomHarnesses.get("dup")?.label, "V2"); + }); + + it("rename removes old key and inserts new key", () => { + handleSaveCustomHarness(makeArgs({ id: "old-rt", label: "Old" })); + handleSaveCustomHarness( + makeArgs({ id: "new-rt", label: "New", originalId: "old-rt" }), + ); + assert.ok( + !mockCustomHarnesses.has("old-rt"), + "old key must be removed on rename", + ); + assert.ok( + mockCustomHarnesses.has("new-rt"), + "new key must be present after rename", + ); + assert.equal(mockCustomHarnesses.get("new-rt")?.label, "New"); + }); +}); + +// ── delete_custom_harness ──────────────────────────────────────────────────── + +describe("handleDeleteCustomHarness", () => { + it("removes an existing entry from the store", () => { + handleSaveCustomHarness(makeArgs({ id: "to-delete" })); + assert.ok(mockCustomHarnesses.has("to-delete")); + + handleDeleteCustomHarness({ id: "to-delete" }); + assert.ok( + !mockCustomHarnesses.has("to-delete"), + "entry must be removed after delete", + ); + }); + + it("is idempotent — deleting non-existent id does not throw", () => { + assert.doesNotThrow( + () => handleDeleteCustomHarness({ id: "never-existed" }), + "delete of non-existent id must not throw", + ); + }); + + it("throws when deleteCustomHarnessError knob is set", () => { + handleSaveCustomHarness(makeArgs({ id: "keep-alive" })); + const config = { mock: { deleteCustomHarnessError: "permission denied" } }; + assert.throws( + () => handleDeleteCustomHarness({ id: "keep-alive" }, config), + /permission denied/, + "must throw the injected error message", + ); + // Entry must remain in store — the error means delete did not complete. + assert.ok( + mockCustomHarnesses.has("keep-alive"), + "entry must remain when delete throws", + ); + }); +}); + +// ── discover integration: store is shared by reference ─────────────────────── + +describe("mergeMockCustomHarnesses", () => { + const seeded = (id, label = id) => ({ id, label, source: "custom" }); + + it("appends a newly saved harness that is not in the seeded catalog", () => { + handleSaveCustomHarness(makeArgs({ id: "added", label: "Added" })); + const merged = mergeMockCustomHarnesses([seeded("preset-a")]); + assert.deepEqual( + merged.map((e) => e.id), + ["preset-a", "added"], + ); + }); + + it("replaces a seeded entry in place on same-id save — no duplicate row", () => { + handleSaveCustomHarness(makeArgs({ id: "seeded-one", label: "V2" })); + const merged = mergeMockCustomHarnesses([ + seeded("preset-a"), + seeded("seeded-one", "V1"), + ]); + assert.deepEqual( + merged.map((e) => e.id), + ["preset-a", "seeded-one"], + "must not duplicate the id", + ); + assert.equal( + merged.find((e) => e.id === "seeded-one").label, + "V2", + "saved entry must win over the seed", + ); + }); + + it("drops a deleted seeded entry (tombstone, not just store removal)", () => { + // The regression: a seeded row has no store entry to delete, so without a + // tombstone the row survived the delete and the spec failed. + handleDeleteCustomHarness({ id: "seeded-one" }); + const merged = mergeMockCustomHarnesses([ + seeded("preset-a"), + seeded("seeded-one"), + ]); + assert.deepEqual( + merged.map((e) => e.id), + ["preset-a"], + ); + }); + + it("drops the vacated old id after a rename and surfaces the new one", () => { + handleSaveCustomHarness( + makeArgs({ id: "new-id", label: "New", originalId: "old-id" }), + ); + const merged = mergeMockCustomHarnesses([seeded("old-id", "Old")]); + assert.deepEqual( + merged.map((e) => e.id), + ["new-id"], + ); + }); + + it("re-saving a deleted id resurrects it", () => { + handleDeleteCustomHarness({ id: "seeded-one" }); + handleSaveCustomHarness(makeArgs({ id: "seeded-one", label: "Back" })); + const merged = mergeMockCustomHarnesses([seeded("seeded-one", "Original")]); + assert.deepEqual( + merged.map((e) => e.id), + ["seeded-one"], + ); + assert.equal(merged[0].label, "Back"); + }); + + it("leaves a seeded catalog untouched when nothing has been mutated", () => { + const base = [seeded("preset-a"), seeded("preset-b")]; + assert.deepEqual( + mergeMockCustomHarnesses(base).map((e) => e.id), + ["preset-a", "preset-b"], + ); + }); +}); + +describe("mockCustomHarnesses Map reference", () => { + it("handler writes are immediately visible to callers that read the exported Map", () => { + // handleDiscoverAcpRuntimes merges this store into the catalog it returns. + // The exported Map is the same object by reference, so writes via the + // handler are visible to any reader of the Map. + assert.equal( + mockCustomHarnesses.size, + 0, + "store must be empty after reset", + ); + + handleSaveCustomHarness(makeArgs({ id: "visible", label: "Visible" })); + assert.equal(mockCustomHarnesses.size, 1); + + const [entry] = Array.from(mockCustomHarnesses.values()); + assert.equal(entry.id, "visible"); + assert.equal(entry.source, "custom"); + }); +}); diff --git a/desktop/src/testing/e2eBridgeCustomHarnesses.ts b/desktop/src/testing/e2eBridgeCustomHarnesses.ts new file mode 100644 index 00000000000..b72ecadd340 --- /dev/null +++ b/desktop/src/testing/e2eBridgeCustomHarnesses.ts @@ -0,0 +1,127 @@ +/** + * In-memory custom harness store for the e2e bridge. + * + * Extracted as a separate module so the handler logic can be unit-tested + * independently of the full e2eBridge.ts context (which requires a browser + * environment and full Playwright setup). + */ +import type { RawAcpRuntimeCatalogEntry } from "../shared/api/tauri.ts"; + +/** In-memory store for custom harnesses saved via `save_custom_harness`. */ +export const mockCustomHarnesses = new Map(); + +/** + * Ids removed via `delete_custom_harness` (or vacated by a rename). + * + * Needed because a test's `acpRuntimesCatalog` seed is static config, not the + * mutation store: deleting a seeded row leaves nothing to remove from + * `mockCustomHarnesses`, so without a tombstone the row would survive the + * delete and the mock would report success while the UI still shows it. + */ +export const mockDeletedCustomHarnesses = new Set(); + +/** Reset the store between tests. */ +export function resetMockCustomHarnesses(): void { + mockCustomHarnesses.clear(); + mockDeletedCustomHarnesses.clear(); +} + +/** + * Overlay the mutation store onto a seeded catalog. + * + * Deleted ids drop out, saved ids replace their seeded entry in place (so a + * same-id edit updates rather than duplicates), and newly added ids append. + */ +export function mergeMockCustomHarnesses( + base: RawAcpRuntimeCatalogEntry[], +): RawAcpRuntimeCatalogEntry[] { + const merged = base.filter( + (entry) => !mockDeletedCustomHarnesses.has(entry.id), + ); + for (const entry of mockCustomHarnesses.values()) { + const index = merged.findIndex((existing) => existing.id === entry.id); + if (index === -1) { + merged.push(entry); + } else { + merged[index] = entry; + } + } + return merged; +} + +/** + * Handle `save_custom_harness`. + * + * Persists the definition into `mockCustomHarnesses` so that the next + * `discover_acp_providers` call includes it. Mirrors the Rust command's + * return shape: an `AcpRuntimeCatalogEntry` for the saved harness. + */ +export function handleSaveCustomHarness(args: { + definition?: { + id?: string; + label?: string; + command?: string; + args?: string[]; + env?: Record; + installInstructionsUrl?: string; + installHint?: string; + }; + originalId?: string | null; +}): RawAcpRuntimeCatalogEntry { + const def = args.definition ?? {}; + const id = def.id ?? ""; + const originalId = args.originalId ?? null; + + // On rename: remove the old entry so the old id is no longer in the catalog. + if (originalId && originalId !== id) { + mockCustomHarnesses.delete(originalId); + mockDeletedCustomHarnesses.add(originalId); + } + // A save resurrects an id that an earlier test step deleted. + mockDeletedCustomHarnesses.delete(id); + + const entry: RawAcpRuntimeCatalogEntry = { + id, + label: def.label ?? id, + avatar_url: "", + availability: "not_installed", // PATH not probed in e2e mock + command: def.command ?? null, + binary_path: null, + default_args: def.args ?? [], + mcp_command: null, + install_hint: def.installHint ?? "", + install_instructions_url: def.installInstructionsUrl ?? "", + can_auto_install: false, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "not_applicable" }, + source: "custom", + // Omit definition_env when the env map is empty — mirrors Rust's BTreeMap + // serialization which skips empty maps so the field is absent on the wire. + definition_env: + def.env && Object.keys(def.env).length > 0 ? def.env : undefined, + login_hint: undefined, + }; + mockCustomHarnesses.set(id, entry); + return entry; +} + +/** + * Handle `delete_custom_harness`. + * Removes the harness from the in-memory store. Idempotent (not-found is OK). + * When `config?.mock?.deleteCustomHarnessError` is set, throws with that message + * to exercise the UI's inline error path. + */ +export function handleDeleteCustomHarness( + args: { id?: string }, + config?: { mock?: { deleteCustomHarnessError?: string } } | undefined, +): void { + const errorMsg = config?.mock?.deleteCustomHarnessError; + if (errorMsg) { + throw new Error(errorMsg); + } + const id = args.id ?? ""; + mockCustomHarnesses.delete(id); + mockDeletedCustomHarnesses.add(id); +} diff --git a/desktop/test-loader-hooks.mjs b/desktop/test-loader-hooks.mjs index c71cc185993..dd6f3432741 100644 --- a/desktop/test-loader-hooks.mjs +++ b/desktop/test-loader-hooks.mjs @@ -54,7 +54,20 @@ const stubModules = new Map([ const STUB_URL_PREFIX = "buzz-test-stub:"; +// Vite resolves asset imports (`./logo.png`, `./logo.png?inline`) to a URL or +// base64 string at bundle time; node's ESM resolver has no such loader and +// throws on the query suffix. Serve an inert string so components that embed +// assets stay unit-testable. +const ASSET_SPECIFIER = /\.(?:png|jpe?g|gif|svg|webp|avif|ico)(?:\?[^/]*)?$/; +const ASSET_URL_PREFIX = "buzz-test-asset:"; + export function resolve(specifier, context, nextResolve) { + if (ASSET_SPECIFIER.test(specifier)) { + return { + shortCircuit: true, + url: `${ASSET_URL_PREFIX}${specifier}`, + }; + } if (stubModules.has(specifier)) { return { shortCircuit: true, @@ -98,6 +111,14 @@ export function resolve(specifier, context, nextResolve) { } export async function load(url, context, nextLoad) { + if (url.startsWith(ASSET_URL_PREFIX)) { + return { + format: "module", + shortCircuit: true, + source: 'export default "test-asset";\n', + }; + } + if (url.startsWith(STUB_URL_PREFIX)) { return { format: "module", diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index d9b3214e648..099c2cb752a 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -701,7 +701,7 @@ test.describe("global agent config screenshots", () => { .locator("p") .filter({ hasText: "Buzz Agent is not installed." }), ).toContainText( - "Buzz Agent is not installed. Visit Settings > Agents to set it up.", + "Buzz Agent is not installed. Ships with the Buzz desktop app. Visit Settings > Agents to set it up.", ); await expect(page.getByTestId("persona-dialog-submit")).toBeDisabled(); }); diff --git a/desktop/tests/e2e/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts new file mode 100644 index 00000000000..e66a85fac96 --- /dev/null +++ b/desktop/tests/e2e/harness-management.spec.ts @@ -0,0 +1,498 @@ +/** + * E2E spec for the Bring-Your-Own-Harness management UI. + * + * Covers: + * - Preset gallery renders with Detected badge for an available preset + * - Preset gallery renders without badge / with install link for a missing preset + * - Add custom harness (form → save → row appears in list) + * - Edit preserves env vars (round-trip through definitionEnv boundary) + * - Same-ID edit replaces entry (no duplicate row) + * - Rename removes old row and shows new row + * - Delete success removes row + * - Delete failure shows error inline (error-injection knob) + * - PATH badge: custom harness row shows Detected when availability === "available" + * - Onboarding navigate: setup-page "More harnesses" click → Settings → Agents (F8) + */ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// ── Shared catalog fixtures ─────────────────────────────────────────────────── + +/** Hermes preset with availability "available" — renders the Detected badge. */ +const HERMES_AVAILABLE = { + id: "hermes", + label: "Hermes", + avatar_url: "", + availability: "available", + command: "hermes-acp", + binary_path: "/usr/local/bin/hermes-acp", + default_args: [], + mcp_command: null, + install_hint: "Install Hermes Agent from hermes-agent.nousresearch.com.", + install_instructions_url: "https://hermes-agent.nousresearch.com", + can_auto_install: false, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "unknown" }, + source: "preset", +} as const; + +/** OpenClaw preset with availability "not_installed" — renders install link. */ +const OPENCLAW_NOT_INSTALLED = { + id: "openclaw", + label: "OpenClaw", + avatar_url: "", + availability: "not_installed", + command: "openclaw", + binary_path: null, + default_args: ["acp"], + mcp_command: null, + install_hint: "Install OpenClaw: npm install -g openclaw@latest.", + install_instructions_url: "https://docs.openclaw.ai/start/getting-started", + can_auto_install: false, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "unknown" }, + source: "preset", +} as const; + +/** Cursor preset — deliberately has NO bundled logo (brand assets not + * licensed for redistribution; see FALLBACK_ONLY_PRESETS). Must render the + * terminal glyph, never initials. */ +const CURSOR_AVAILABLE = { + id: "cursor", + label: "Cursor", + avatar_url: "", + availability: "available", + command: "cursor-agent", + binary_path: "/usr/local/bin/cursor-agent", + default_args: [], + mcp_command: null, + install_hint: "Install Cursor CLI from cursor.com.", + install_instructions_url: "https://cursor.com/cli", + can_auto_install: false, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "unknown" }, + source: "preset", +} as const; + +/** Custom harness entry already persisted — shown in the custom list. */ +function makeCustomEntry( + overrides: { + id?: string; + label?: string; + command?: string; + availability?: "available" | "not_installed"; + definition_env?: Record; + } = {}, +) { + return { + id: overrides.id ?? "my-custom-agent", + label: overrides.label ?? "My Custom Agent", + avatar_url: "", + availability: overrides.availability ?? "not_installed", + command: overrides.command ?? "my-custom-acp", + binary_path: + overrides.availability === "available" + ? "/usr/local/bin/my-custom-acp" + : null, + default_args: [], + mcp_command: null, + install_hint: "", + install_instructions_url: "", + can_auto_install: false, + requires_external_cli: true, + underlying_cli_path: null, + node_required: false, + auth_status: { status: "unknown" }, + source: "custom", + definition_env: overrides.definition_env, + }; +} + +// ── Navigation helpers ──────────────────────────────────────────────────────── + +/** + * Open Settings → Agents through the normal UI path. + * CI serves the app as a static SPA; direct navigation to /settings 404s + * before the client router starts. + */ +async function openHarnessSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); + await expect(page.getByTestId("settings-harness-management")).toBeVisible({ + timeout: 10_000, + }); +} + +/** + * Fill and submit the custom harness add/edit form. + * Caller must have the form visible before calling. + */ +async function fillHarnessForm( + page: import("@playwright/test").Page, + values: { + label: string; + id: string; + command: string; + env?: Array<{ key: string; value: string }>; + }, +) { + await page.fill("#ch-label", values.label); + // ID may auto-derive; overwrite it. + await page.fill("#ch-id", values.id); + await page.fill("#ch-command", values.command); + for (const pair of values.env ?? []) { + await page.getByRole("button", { name: "Add env var" }).click(); + // Fill last appended row. + const keyInputs = page.locator('input[placeholder="KEY"]'); + const valInputs = page.locator('input[placeholder="value"]'); + await keyInputs.last().fill(pair.key); + await valInputs.last().fill(pair.value); + } +} + +// ── Preset gallery ──────────────────────────────────────────────────────────── + +test.describe("preset gallery", () => { + test("detected preset shows Detected badge", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED], + }); + await openHarnessSettings(page); + + const hermesCard = page.getByTestId("harness-preset-hermes"); + await expect(hermesCard).toBeVisible(); + await expect(hermesCard.getByText("Detected")).toBeVisible(); + // Not-installed preset must NOT show Detected badge. + const openclawCard = page.getByTestId("harness-preset-openclaw"); + await expect(openclawCard).toBeVisible(); + await expect(openclawCard.getByText("Detected")).not.toBeVisible(); + }); + + test("not-installed preset shows Install link, not Detected badge", async ({ + page, + }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED], + }); + await openHarnessSettings(page); + + const openclawCard = page.getByTestId("harness-preset-openclaw"); + await expect(openclawCard).toBeVisible(); + await expect(openclawCard.getByText("Install")).toBeVisible(); + await expect(openclawCard.getByText("Detected")).not.toBeVisible(); + }); +}); + +// ── Preset logos in the Agent runtimes list ────────────────────────────────── + +test("Agent runtimes rows render bundled preset logos, not initials", async ({ + page, +}) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + HERMES_AVAILABLE, + OPENCLAW_NOT_INSTALLED, + CURSOR_AVAILABLE, + ], + }); + await openHarnessSettings(page); + + // Preset rows in "Agent runtimes" must show the same bundled logo the + // preset gallery uses (PRESET_LOGOS via RuntimeIcon), even though presets + // emit an empty avatar_url (the no-remote-icon security line). + for (const [id, file] of [ + ["hermes", "/harness-logos/hermes.png"], + ["openclaw", "/harness-logos/openclaw.svg"], + ] as const) { + const logo = page.getByTestId(`doctor-runtime-logo-${id}`); + await expect(logo).toBeVisible(); + await expect(logo.locator("img")).toHaveAttribute("src", file); + } + + // Cursor has no bundled logo (licensing) — it must fall through to + // RuntimeIcon's terminal glyph like the preset gallery, not initials. + const cursorLogo = page.getByTestId("doctor-runtime-logo-cursor"); + await expect(cursorLogo).toBeVisible(); + await expect(cursorLogo.locator("svg")).toBeVisible(); + await expect(cursorLogo.locator("img")).not.toBeVisible(); + await expect(cursorLogo).not.toContainText("C"); +}); + +// ── Custom harness add ──────────────────────────────────────────────────────── + +test.describe("add custom harness", () => { + test("form saves and row appears in list", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [HERMES_AVAILABLE, OPENCLAW_NOT_INSTALLED], + }); + await openHarnessSettings(page); + + // No custom rows yet. + // The list container may exist but have no row children. + await expect( + page.getByTestId("custom-harness-row-my-custom-agent"), + ).not.toBeVisible(); + + // Open the add form. + await page.getByTestId("harness-add-custom-button").click(); + await expect(page.getByTestId("custom-harness-form")).toBeVisible(); + + await fillHarnessForm(page, { + label: "My Custom Agent", + id: "my-custom-agent", + command: "my-custom-acp", + }); + + // Submit. + await page + .getByTestId("custom-harness-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + + // Row must appear in the list after save. + await expect( + page.getByTestId("custom-harness-row-my-custom-agent"), + ).toBeVisible({ timeout: 5_000 }); + }); + + test("edit preserves env vars (definitionEnv round-trip)", async ({ + page, + }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + HERMES_AVAILABLE, + OPENCLAW_NOT_INSTALLED, + makeCustomEntry({ definition_env: { MY_API_KEY: "sk-test" } }), + ], + }); + await openHarnessSettings(page); + + // Open edit form for the existing custom entry. + await expect( + page.getByTestId("custom-harness-row-my-custom-agent"), + ).toBeVisible(); + await page.getByTestId("custom-harness-edit-my-custom-agent").click(); + await expect(page.getByTestId("custom-harness-form")).toBeVisible(); + + // Env KEY and value must be pre-populated. + await expect(page.locator('input[placeholder="KEY"]').first()).toHaveValue( + "MY_API_KEY", + ); + await expect( + page.locator('input[placeholder="value"]').first(), + ).toHaveValue("sk-test"); + }); + + test("same-ID edit replaces row — no duplicate", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + HERMES_AVAILABLE, + OPENCLAW_NOT_INSTALLED, + makeCustomEntry({ label: "V1 Label" }), + ], + }); + await openHarnessSettings(page); + + // Edit, keep same ID, change label. + await page.getByTestId("custom-harness-edit-my-custom-agent").click(); + await expect(page.getByTestId("custom-harness-form")).toBeVisible(); + await page.fill("#ch-label", "V2 Label"); + await page + .getByTestId("custom-harness-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + + // Exactly one row with the same ID; label updated. + const rows = page.locator( + '[data-testid^="custom-harness-row-my-custom-agent"]', + ); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText("V2 Label"); + }); + + test("rename removes old row and inserts new row", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + HERMES_AVAILABLE, + OPENCLAW_NOT_INSTALLED, + makeCustomEntry({ id: "old-harness", label: "Old" }), + ], + }); + await openHarnessSettings(page); + + await page.getByTestId("custom-harness-edit-old-harness").click(); + await expect(page.getByTestId("custom-harness-form")).toBeVisible(); + await page.fill("#ch-label", "New Harness"); + await page.fill("#ch-id", "new-harness"); + await page + .getByTestId("custom-harness-form") + .getByRole("button", { name: "Save", exact: true }) + .click(); + + // Old row gone; new row present. + await expect( + page.getByTestId("custom-harness-row-old-harness"), + ).not.toBeVisible({ timeout: 5_000 }); + await expect( + page.getByTestId("custom-harness-row-new-harness"), + ).toBeVisible({ timeout: 5_000 }); + }); +}); + +// ── Delete flow ─────────────────────────────────────────────────────────────── + +test.describe("delete custom harness", () => { + test("delete success removes the row", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + HERMES_AVAILABLE, + OPENCLAW_NOT_INSTALLED, + makeCustomEntry(), + ], + }); + await openHarnessSettings(page); + + // Enter confirm-delete mode. + await page.getByTestId("custom-harness-delete-my-custom-agent").click(); + // The confirm button must appear. + await expect( + page.getByTestId("custom-harness-delete-confirm-my-custom-agent"), + ).toBeVisible(); + await page + .getByTestId("custom-harness-delete-confirm-my-custom-agent") + .click(); + + // Row disappears after successful delete. + await expect( + page.getByTestId("custom-harness-row-my-custom-agent"), + ).not.toBeVisible({ timeout: 5_000 }); + }); + + test("delete failure shows inline error and keeps the row", async ({ + page, + }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + HERMES_AVAILABLE, + OPENCLAW_NOT_INSTALLED, + makeCustomEntry(), + ], + deleteCustomHarnessError: "permission denied: could not remove file", + }); + await openHarnessSettings(page); + + await page.getByTestId("custom-harness-delete-my-custom-agent").click(); + await page + .getByTestId("custom-harness-delete-confirm-my-custom-agent") + .click(); + + // Error text visible; row still present. + await expect( + page.getByText("permission denied: could not remove file"), + ).toBeVisible({ timeout: 5_000 }); + await expect( + page.getByTestId("custom-harness-row-my-custom-agent"), + ).toBeVisible(); + }); +}); + +// ── PATH badge on custom harness row ───────────────────────────────────────── + +test("custom harness row shows Detected badge when command is on PATH", async ({ + page, +}) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + HERMES_AVAILABLE, + OPENCLAW_NOT_INSTALLED, + makeCustomEntry({ availability: "available" }), + ], + }); + await openHarnessSettings(page); + + const row = page.getByTestId("custom-harness-row-my-custom-agent"); + await expect(row).toBeVisible(); + await expect(row.getByText("Detected")).toBeVisible(); +}); + +// ── F8: onboarding navigate-after-complete ──────────────────────────────────── +// +// Verifies the parent-owned route intent introduced in B-8: +// 1. User reaches the machine-onboarding setup page. +// 2. Clicks "More harnesses" (onboarding-setup-more-harnesses). +// 3. App completes onboarding and immediately navigates to Settings → Agents. +// +// This test exercises the real App.tsx effect that gates router.navigate() on +// machine.stage === "ready", which the pure-logic tests in +// postOnboardingNav.test.mjs cannot cover (they simulate the predicate, not +// the real render path). + +test("onboarding setup More-harnesses click navigates to Settings → Agents", async ({ + page, +}) => { + // Start with a fresh machine (no machine-onboarding-complete flag). + // skipCommunitySeed: true so the user goes through machine onboarding. + // skipOnboardingSeed: true so the community/identity banner doesn't appear. + await installMockBridge(page, undefined, { + skipCommunitySeed: true, + skipOnboardingSeed: true, + }); + // Seed a community stamped with a *foreign* pubkey. This is the only shape + // that satisfies both preconditions of this test at once: + // - machine onboarding must still run, so the community must NOT vouch for + // the active identity (migrateMachineOnboardingCompletion only accepts a + // community whose recorded pubkey matches — see machineOnboarding.ts:70). + // - after onboarding completes, useCommunityInit must NOT report + // needsSetup, or App.tsx:499 renders WelcomeSetup instead of the router + // and the navigation lands on a screen that has no settings tree. + // The default seed vouches (it uses the active pubkey) and skipping it + // entirely leaves zero communities, so neither default gets there. + await page.addInitScript(() => { + const communityId = "e2e-default-community"; + window.localStorage.setItem( + "buzz-communities", + JSON.stringify([ + { + id: communityId, + name: "E2E Test", + relayUrl: "ws://127.0.0.1:7777", + pubkey: "f".repeat(64), + addedAt: new Date().toISOString(), + }, + ]), + ); + window.localStorage.setItem("buzz-active-community-id", communityId); + }); + await page.goto("/"); + + // Reach the setup page: create a new identity key → skip backup step. + await page.getByRole("button", { name: "Create a new identity key" }).click(); + await expect(page.getByTestId("onboarding-page-backup")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("onboarding-next").click(); + + // Now on the setup page. + await expect( + page.getByRole("heading", { name: "Set up your agent harnesses" }), + ).toBeVisible({ timeout: 10_000 }); + + // Click the "More harnesses" link — fires navigateToAgentSettings. + await page.getByTestId("onboarding-setup-more-harnesses").click(); + + // After onboarding completes + router mounts, the app must land on + // Settings → Agents (harness management section visible). + await expect(page.getByTestId("settings-harness-management")).toBeVisible({ + timeout: 15_000, + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2b9bd79f821..d5bd6ae0ddd 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -154,6 +154,8 @@ type MockBridgeOptions = { acpRuntimesDelayMs?: number; acpAuthMethods?: Record[] }>; acpAuthMethodsError?: string; + /** When set, the `delete_custom_harness` mock command throws with this message. */ + deleteCustomHarnessError?: string; connectAcpRuntimeResult?: { launched: boolean }; connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; From 63c62fcf3eb5321262e8b7c4e299d110de330884 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:17:14 +0000 Subject: [PATCH 09/71] chore(deps): update dependency @tanstack/react-virtual to v3.14.8 (#3057) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@tanstack/react-virtual](https://tanstack.com/virtual) ([source](https://redirect.github.com/TanStack/virtual/tree/HEAD/packages/react-virtual)) | [`3.14.6` → `3.14.8`](https://renovatebot.com/diffs/npm/@tanstack%2freact-virtual/3.14.6/3.14.8) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@tanstack%2freact-virtual/3.14.8?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@tanstack%2freact-virtual/3.14.6/3.14.8?slim=true) | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/1) for more information. --- ### Release Notes
TanStack/virtual (@​tanstack/react-virtual) ### [`v3.14.8`](https://redirect.github.com/TanStack/virtual/blob/HEAD/packages/react-virtual/CHANGELOG.md#3148) [Compare Source](https://redirect.github.com/TanStack/virtual/compare/@tanstack/react-virtual@3.14.7...@tanstack/react-virtual@3.14.8) ##### Patch Changes - [#​1237](https://redirect.github.com/TanStack/virtual/pull/1237) [`aa536e7`](https://redirect.github.com/TanStack/virtual/commit/aa536e7746a88d9f55ca8a4b50d2f548a888fea6) - Fix a gap at the top of the list after an end-anchored prepend in `directDomUpdates` mode. The prepend grows the total size and bumps `scrollOffset` to the new bottom in the same pass, but the size container's height was written *after* `_willUpdate` synced the scroll position — so the browser clamped the `scrollTop` write to the stale (shorter) `scrollHeight`, leaving whitespace at the top until the next scroll. The container is now grown before the scroll sync. Only affected `directDomUpdates` mode (React-rendered sizers receive their height during render). - Updated dependencies \[[`7ae32b5`](https://redirect.github.com/TanStack/virtual/commit/7ae32b55887fd044a48c788546cd940279b338e0)]: - [@​tanstack/virtual-core](https://redirect.github.com/tanstack/virtual-core)@​3.17.6 ### [`v3.14.7`](https://redirect.github.com/TanStack/virtual/blob/HEAD/packages/react-virtual/CHANGELOG.md#3147) [Compare Source](https://redirect.github.com/TanStack/virtual/compare/@tanstack/react-virtual@3.14.6...@tanstack/react-virtual@3.14.7) ##### Patch Changes - Updated dependencies \[[`1e3b908`](https://redirect.github.com/TanStack/virtual/commit/1e3b908705e04e45be2615f2277580cb09f5cdef), [`7dcfc07`](https://redirect.github.com/TanStack/virtual/commit/7dcfc07b877479697124157d3124c09537b87a75)]: - [@​tanstack/virtual-core](https://redirect.github.com/tanstack/virtual-core)@​3.17.5
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/block/buzz). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3516e853fd..ecc070921e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 1.170.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-virtual': specifier: ^3.14.2 - version: 3.14.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 3.14.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tauri-apps/api': specifier: ~2.11 version: 2.11.0 @@ -1680,8 +1680,8 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-virtual@3.14.6': - resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==} + '@tanstack/react-virtual@3.14.8': + resolution: {integrity: sha512-O39GJQpAYEJcIu3uN1//YtmhjSEOyw75vg9CKCatBDPiD5hKtZQoJHfferyrB/LdOD3UWaoMLWtdEjarwIwdDw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1722,8 +1722,8 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} - '@tanstack/virtual-core@3.17.4': - resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==} + '@tanstack/virtual-core@3.17.6': + resolution: {integrity: sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==} '@tanstack/virtual-file-routes@1.162.0': resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} @@ -4878,9 +4878,9 @@ snapshots: react-dom: 19.2.7(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7) - '@tanstack/react-virtual@3.14.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@tanstack/react-virtual@3.14.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@tanstack/virtual-core': 3.17.4 + '@tanstack/virtual-core': 3.17.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -4941,7 +4941,7 @@ snapshots: '@tanstack/store@0.9.3': {} - '@tanstack/virtual-core@3.17.4': {} + '@tanstack/virtual-core@3.17.6': {} '@tanstack/virtual-file-routes@1.162.0': {} From 070fb6a16153b47d88953e1a2db5c2f241b838b5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 01:09:48 -0700 Subject: [PATCH 10/71] chore(deps): update radix-ui-primitives monorepo (#3063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@radix-ui/react-alert-dialog](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog)) | [`1.1.19` → `1.1.23`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-alert-dialog/1.1.19/1.1.23) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-alert-dialog/1.1.23?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-alert-dialog/1.1.19/1.1.23?slim=true) | | [@radix-ui/react-checkbox](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox)) | [`1.3.7` → `1.3.11`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-checkbox/1.3.7/1.3.11) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-checkbox/1.3.11?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-checkbox/1.3.7/1.3.11?slim=true) | | [@radix-ui/react-dialog](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog)) | [`1.1.19` → `1.1.23`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-dialog/1.1.19/1.1.23) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-dialog/1.1.23?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-dialog/1.1.19/1.1.23?slim=true) | | [@radix-ui/react-dismissable-layer](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/dismissable-layer)) | [`1.1.15` → `1.1.19`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-dismissable-layer/1.1.15/1.1.19) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-dismissable-layer/1.1.19?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-dismissable-layer/1.1.15/1.1.19?slim=true) | | [@radix-ui/react-dropdown-menu](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu)) | [`2.1.20` → `2.1.24`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-dropdown-menu/2.1.20/2.1.24) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-dropdown-menu/2.1.24?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-dropdown-menu/2.1.20/2.1.24?slim=true) | | [@radix-ui/react-focus-scope](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/focus-scope)) | [`1.1.12` → `1.1.16`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-focus-scope/1.1.12/1.1.16) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-focus-scope/1.1.16?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-focus-scope/1.1.12/1.1.16?slim=true) | | [@radix-ui/react-popover](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/popover)) | [`1.1.19` → `1.1.23`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-popover/1.1.19/1.1.23) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-popover/1.1.23?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-popover/1.1.19/1.1.23?slim=true) | | [@radix-ui/react-separator](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/separator)) | [`1.1.11` → `1.1.15`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-separator/1.1.11/1.1.15) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-separator/1.1.15?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-separator/1.1.11/1.1.15?slim=true) | | [@radix-ui/react-slot](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/slot)) | [`1.3.0` → `1.3.3`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-slot/1.3.0/1.3.3) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-slot/1.3.3?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-slot/1.3.0/1.3.3?slim=true) | | [@radix-ui/react-tabs](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs)) | [`1.1.17` → `1.1.21`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-tabs/1.1.17/1.1.21) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-tabs/1.1.21?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-tabs/1.1.17/1.1.21?slim=true) | | [@radix-ui/react-toggle](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/toggle)) | [`1.1.14` → `1.1.18`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-toggle/1.1.14/1.1.18) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-toggle/1.1.18?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-toggle/1.1.14/1.1.18?slim=true) | | [@radix-ui/react-tooltip](https://radix-ui.com/primitives) ([source](https://redirect.github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip)) | [`1.2.12` → `1.2.16`](https://renovatebot.com/diffs/npm/@radix-ui%2freact-tooltip/1.2.12/1.2.16) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@radix-ui%2freact-tooltip/1.2.16?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@radix-ui%2freact-tooltip/1.2.12/1.2.16?slim=true) | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/1) for more information. --- ### Release Notes
radix-ui/primitives (@​radix-ui/react-alert-dialog) ### [`v1.1.23`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/alert-dialog/CHANGELOG.md#1123) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-context@1.2.2`, `@radix-ui/react-dialog@1.1.23`, `@radix-ui/react-primitive@2.1.10` ### [`v1.1.22`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/alert-dialog/CHANGELOG.md#1122) - Updated dependencies: `@radix-ui/react-dialog@1.1.22`, `@radix-ui/react-primitive@2.1.9` ### [`v1.1.21`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/alert-dialog/CHANGELOG.md#1121) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-context@1.2.1`, `@radix-ui/react-dialog@1.1.21`, `@radix-ui/react-primitive@2.1.8` ### [`v1.1.20`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/alert-dialog/CHANGELOG.md#1120) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-dialog@1.1.20`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-context@1.2.0`, `@radix-ui/react-primitive@2.1.7`
radix-ui/primitives (@​radix-ui/react-checkbox) ### [`v1.3.11`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/checkbox/CHANGELOG.md#1311) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-context@1.2.2`, `@radix-ui/react-presence@1.1.10`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-use-controllable-state@1.2.6`, `@radix-ui/react-use-size@1.1.4` ### [`v1.3.10`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/checkbox/CHANGELOG.md#1310) - Updated dependencies: `@radix-ui/react-primitive@2.1.9` ### [`v1.3.9`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/checkbox/CHANGELOG.md#139) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-context@1.2.1`, `@radix-ui/react-presence@1.1.9`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-use-controllable-state@1.2.5`, `@radix-ui/react-use-size@1.1.3` ### [`v1.3.8`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/checkbox/CHANGELOG.md#138) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Fixed a bug where updating a `Checkbox`, `Switch`, or `RadioGroup` value programmatically (eg. a "select all" control) while inside a `
` would dispatch a `click` event from the hidden bubble input that propagated to ancestor `onClick` handlers. - Updated dependencies: `@radix-ui/react-presence@1.1.8`, `@radix-ui/react-use-controllable-state@1.2.4`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-context@1.2.0`, `@radix-ui/react-primitive@2.1.7`, `@radix-ui/react-use-size@1.1.2`
radix-ui/primitives (@​radix-ui/react-dialog) ### [`v1.1.23`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dialog/CHANGELOG.md#1123) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-context@1.2.2`, `@radix-ui/react-dismissable-layer@1.1.19`, `@radix-ui/react-focus-guards@1.1.6`, `@radix-ui/react-focus-scope@1.1.16`, `@radix-ui/react-id@1.1.4`, `@radix-ui/react-portal@1.1.17`, `@radix-ui/react-presence@1.1.10`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-slot@1.3.3`, `@radix-ui/react-use-controllable-state@1.2.6`, `@radix-ui/react-use-layout-effect@1.1.4` ### [`v1.1.22`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dialog/CHANGELOG.md#1122) - Updated dependencies: `@radix-ui/react-slot@1.3.2`, `@radix-ui/react-primitive@2.1.9`, `@radix-ui/react-dismissable-layer@1.1.18`, `@radix-ui/react-focus-scope@1.1.15`, `@radix-ui/react-portal@1.1.16` ### [`v1.1.21`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dialog/CHANGELOG.md#1121) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-context@1.2.1`, `@radix-ui/react-dismissable-layer@1.1.17`, `@radix-ui/react-focus-guards@1.1.5`, `@radix-ui/react-focus-scope@1.1.14`, `@radix-ui/react-id@1.1.3`, `@radix-ui/react-portal@1.1.15`, `@radix-ui/react-presence@1.1.9`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-slot@1.3.1`, `@radix-ui/react-use-controllable-state@1.2.5`, `@radix-ui/react-use-layout-effect@1.1.3` ### [`v1.1.20`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dialog/CHANGELOG.md#1120) - Fixed broken ARIA references in Dialogs where a title or description elements are not rendered. - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-dismissable-layer@1.1.16`, `@radix-ui/react-focus-scope@1.1.13`, `@radix-ui/react-portal@1.1.14`, `@radix-ui/react-presence@1.1.8`, `@radix-ui/react-use-controllable-state@1.2.4`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-context@1.2.0`, `@radix-ui/react-focus-guards@1.1.4`, `@radix-ui/react-id@1.1.2`, `@radix-ui/react-primitive@2.1.7`, `@radix-ui/react-slot@1.3.0`, `@radix-ui/react-use-layout-effect@1.1.2`
radix-ui/primitives (@​radix-ui/react-dismissable-layer) ### [`v1.1.19`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dismissable-layer/CHANGELOG.md#1119) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-use-callback-ref@1.1.4`, `@radix-ui/react-use-effect-event@0.0.5` ### [`v1.1.18`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dismissable-layer/CHANGELOG.md#1118) - Updated dependencies: `@radix-ui/react-primitive@2.1.9` ### [`v1.1.17`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dismissable-layer/CHANGELOG.md#1117) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-use-callback-ref@1.1.3`, `@radix-ui/react-use-effect-event@0.0.4` ### [`v1.1.16`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dismissable-layer/CHANGELOG.md#1116) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/primitive@1.1.6`, `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-primitive@2.1.7`, `@radix-ui/react-use-callback-ref@1.1.2`, `@radix-ui/react-use-effect-event@0.0.3`
radix-ui/primitives (@​radix-ui/react-dropdown-menu) ### [`v2.1.24`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dropdown-menu/CHANGELOG.md#2124) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-context@1.2.2`, `@radix-ui/react-id@1.1.4`, `@radix-ui/react-menu@2.1.24`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-use-controllable-state@1.2.6` ### [`v2.1.23`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dropdown-menu/CHANGELOG.md#2123) - Updated dependencies: `@radix-ui/react-menu@2.1.23`, `@radix-ui/react-primitive@2.1.9` ### [`v2.1.22`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dropdown-menu/CHANGELOG.md#2122) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-context@1.2.1`, `@radix-ui/react-id@1.1.3`, `@radix-ui/react-menu@2.1.22`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-use-controllable-state@1.2.5` ### [`v2.1.21`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/dropdown-menu/CHANGELOG.md#2121) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-menu@2.1.21`, `@radix-ui/react-use-controllable-state@1.2.4`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-context@1.2.0`, `@radix-ui/react-id@1.1.2`, `@radix-ui/react-primitive@2.1.7`
radix-ui/primitives (@​radix-ui/react-focus-scope) ### [`v1.1.16`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/focus-scope/CHANGELOG.md#1116) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-use-callback-ref@1.1.4` ### [`v1.1.15`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/focus-scope/CHANGELOG.md#1115) - Updated dependencies: `@radix-ui/react-primitive@2.1.9` ### [`v1.1.14`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/focus-scope/CHANGELOG.md#1114) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-use-callback-ref@1.1.3` ### [`v1.1.13`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/focus-scope/CHANGELOG.md#1113) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-primitive@2.1.7`, `@radix-ui/react-use-callback-ref@1.1.2`
radix-ui/primitives (@​radix-ui/react-popover) ### [`v1.1.23`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/popover/CHANGELOG.md#1123) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-context@1.2.2`, `@radix-ui/react-dismissable-layer@1.1.19`, `@radix-ui/react-focus-guards@1.1.6`, `@radix-ui/react-focus-scope@1.1.16`, `@radix-ui/react-id@1.1.4`, `@radix-ui/react-popper@1.3.7`, `@radix-ui/react-portal@1.1.17`, `@radix-ui/react-presence@1.1.10`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-slot@1.3.3`, `@radix-ui/react-use-controllable-state@1.2.6` ### [`v1.1.22`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/popover/CHANGELOG.md#1122) - Updated dependencies: `@radix-ui/react-slot@1.3.2`, `@radix-ui/react-primitive@2.1.9`, `@radix-ui/react-dismissable-layer@1.1.18`, `@radix-ui/react-focus-scope@1.1.15`, `@radix-ui/react-popper@1.3.6`, `@radix-ui/react-portal@1.1.16` ### [`v1.1.21`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/popover/CHANGELOG.md#1121) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-context@1.2.1`, `@radix-ui/react-dismissable-layer@1.1.17`, `@radix-ui/react-focus-guards@1.1.5`, `@radix-ui/react-focus-scope@1.1.14`, `@radix-ui/react-id@1.1.3`, `@radix-ui/react-popper@1.3.5`, `@radix-ui/react-portal@1.1.15`, `@radix-ui/react-presence@1.1.9`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-slot@1.3.1`, `@radix-ui/react-use-controllable-state@1.2.5`, `@radix-ui/react-use-layout-effect@1.1.3` ### [`v1.1.20`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/popover/CHANGELOG.md#1120) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-popper@1.3.4`, `@radix-ui/react-dismissable-layer@1.1.16`, `@radix-ui/react-focus-scope@1.1.13`, `@radix-ui/react-portal@1.1.14`, `@radix-ui/react-presence@1.1.8`, `@radix-ui/react-use-controllable-state@1.2.4`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-context@1.2.0`, `@radix-ui/react-focus-guards@1.1.4`, `@radix-ui/react-id@1.1.2`, `@radix-ui/react-primitive@2.1.7`, `@radix-ui/react-slot@1.3.0`
radix-ui/primitives (@​radix-ui/react-separator) ### [`v1.1.15`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/separator/CHANGELOG.md#1115) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-primitive@2.1.10` ### [`v1.1.14`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/separator/CHANGELOG.md#1114) - Updated dependencies: `@radix-ui/react-primitive@2.1.9` ### [`v1.1.13`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/separator/CHANGELOG.md#1113) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/react-primitive@2.1.8` ### [`v1.1.12`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/separator/CHANGELOG.md#1112) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-primitive@2.1.7`
radix-ui/primitives (@​radix-ui/react-slot) ### [`v1.3.3`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/slot/CHANGELOG.md#132-133) - Reverted breaking changes that caused compatibility issues with React Server Components. ### [`v1.3.2`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/slot/CHANGELOG.md#132-133) - Reverted breaking changes that caused compatibility issues with React Server Components. ### [`v1.3.1`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/slot/CHANGELOG.md#131) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`
radix-ui/primitives (@​radix-ui/react-tabs) ### [`v1.1.21`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tabs/CHANGELOG.md#1121) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-context@1.2.2`, `@radix-ui/react-direction@1.1.4`, `@radix-ui/react-id@1.1.4`, `@radix-ui/react-presence@1.1.10`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-roving-focus@1.1.19`, `@radix-ui/react-use-controllable-state@1.2.6` ### [`v1.1.20`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tabs/CHANGELOG.md#1120) - Updated dependencies: `@radix-ui/react-primitive@2.1.9`, `@radix-ui/react-roving-focus@1.1.18` ### [`v1.1.19`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tabs/CHANGELOG.md#1119) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-context@1.2.1`, `@radix-ui/react-direction@1.1.3`, `@radix-ui/react-id@1.1.3`, `@radix-ui/react-presence@1.1.9`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-roving-focus@1.1.17`, `@radix-ui/react-use-controllable-state@1.2.5` ### [`v1.1.18`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tabs/CHANGELOG.md#1118) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-presence@1.1.8`, `@radix-ui/react-roving-focus@1.1.16`, `@radix-ui/react-use-controllable-state@1.2.4`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-context@1.2.0`, `@radix-ui/react-direction@1.1.2`, `@radix-ui/react-id@1.1.2`, `@radix-ui/react-primitive@2.1.7`
radix-ui/primitives (@​radix-ui/react-toggle) ### [`v1.1.18`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/toggle/CHANGELOG.md#1118) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-use-controllable-state@1.2.6` ### [`v1.1.17`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/toggle/CHANGELOG.md#1117) - Updated dependencies: `@radix-ui/react-primitive@2.1.9` ### [`v1.1.16`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/toggle/CHANGELOG.md#1116) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-use-controllable-state@1.2.5` ### [`v1.1.15`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/toggle/CHANGELOG.md#1115) - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-use-controllable-state@1.2.4`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-primitive@2.1.7`
radix-ui/primitives (@​radix-ui/react-tooltip) ### [`v1.2.16`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tooltip/CHANGELOG.md#1216) - Reverted breaking changes that caused compatibility issues with React Server Components. - Updated dependencies: `@radix-ui/react-compose-refs@1.1.5`, `@radix-ui/react-context@1.2.2`, `@radix-ui/react-dismissable-layer@1.1.19`, `@radix-ui/react-id@1.1.4`, `@radix-ui/react-popper@1.3.7`, `@radix-ui/react-portal@1.1.17`, `@radix-ui/react-presence@1.1.10`, `@radix-ui/react-primitive@2.1.10`, `@radix-ui/react-slot@1.3.3`, `@radix-ui/react-use-controllable-state@1.2.6`, `@radix-ui/react-use-layout-effect@1.1.4`, `@radix-ui/react-visually-hidden@1.2.11` ### [`v1.2.15`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tooltip/CHANGELOG.md#1215) - Updated dependencies: `@radix-ui/react-slot@1.3.2`, `@radix-ui/react-primitive@2.1.9`, `@radix-ui/react-dismissable-layer@1.1.18`, `@radix-ui/react-popper@1.3.6`, `@radix-ui/react-portal@1.1.16`, `@radix-ui/react-visually-hidden@1.2.10` ### [`v1.2.14`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tooltip/CHANGELOG.md#1214) - Republish through CI to attach provenance attestations. The previous versions of these packages were published manually outside of CI and therefore shipped without provenance; this patch re-releases the same code through the CI pipeline so every package includes an attestation. - Updated dependencies: `@radix-ui/primitive@1.1.7`, `@radix-ui/react-compose-refs@1.1.4`, `@radix-ui/react-context@1.2.1`, `@radix-ui/react-dismissable-layer@1.1.17`, `@radix-ui/react-id@1.1.3`, `@radix-ui/react-popper@1.3.5`, `@radix-ui/react-portal@1.1.15`, `@radix-ui/react-presence@1.1.9`, `@radix-ui/react-primitive@2.1.8`, `@radix-ui/react-slot@1.3.1`, `@radix-ui/react-use-controllable-state@1.2.5`, `@radix-ui/react-use-layout-effect@1.1.3`, `@radix-ui/react-visually-hidden@1.2.9` ### [`v1.2.13`](https://redirect.github.com/radix-ui/primitives/blob/HEAD/packages/react/tooltip/CHANGELOG.md#1213) - Fixed a bug where `Tooltip.Content` children were mounted to the DOM twice. - Improved tree-shaking so bundlers can drop unused components. Component parts are now marked `/* @​__PURE__ */` and use named render functions instead of `Component.displayName = ...` assignments, which previously prevented dead-code elimination with some bundlers. - Updated dependencies: `@radix-ui/react-popper@1.3.4`, `@radix-ui/react-dismissable-layer@1.1.16`, `@radix-ui/react-portal@1.1.14`, `@radix-ui/react-presence@1.1.8`, `@radix-ui/react-visually-hidden@1.2.8`, `@radix-ui/react-use-controllable-state@1.2.4`, `@radix-ui/primitive@1.1.6`, `@radix-ui/react-compose-refs@1.1.3`, `@radix-ui/react-context@1.2.0`, `@radix-ui/react-id@1.1.2`, `@radix-ui/react-primitive@2.1.7`, `@radix-ui/react-slot@1.3.0`, `@radix-ui/react-use-layout-effect@1.1.2`
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/block/buzz). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 571 ++++++++++++++++++++++++-------------------- pnpm-workspace.yaml | 2 +- 2 files changed, 310 insertions(+), 263 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ecc070921e8..a8cb02efd4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@radix-ui/react-dismissable-layer': 1.1.15 + '@radix-ui/react-dismissable-layer': 1.1.19 patchedDependencies: isomorphic-git: e9b414a60d4cf1d8aa18f7a779483984e821989967c235662566e94ef0238d3f @@ -88,46 +88,46 @@ importers: version: 0.10.35 '@radix-ui/react-alert-dialog': specifier: ^1.1.15 - version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-checkbox': specifier: ^1.3.3 - version: 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.3.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-context-menu': specifier: ^2.2.16 version: 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-dialog': specifier: ^1.1.15 - version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-dropdown-menu': specifier: ^2.1.16 - version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-scope': specifier: ^1.1.8 - version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-popover': specifier: ^1.1.15 - version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-separator': specifier: ^1.1.8 - version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-slot': specifier: ^1.2.4 - version: 1.3.0(@types/react@19.2.17)(react@19.2.7) + version: 1.3.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-switch': specifier: ^1.2.6 version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-tabs': specifier: ^1.1.13 - version: 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-toggle': specifier: ^1.1.10 - version: 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-tooltip': specifier: ^1.2.8 - version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tanstack/react-query': specifier: ^5.90.21 version: 5.100.14(react@19.2.7) @@ -296,10 +296,10 @@ importers: version: 4.6.2 '@radix-ui/react-slot': specifier: ^1.2.4 - version: 1.3.0(@types/react@19.2.17)(react@19.2.7) + version: 1.3.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-tooltip': specifier: ^1.2.8 - version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.20(tailwindcss@4.3.0) @@ -718,11 +718,11 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - '@radix-ui/primitive@1.1.5': - resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} - '@radix-ui/react-alert-dialog@1.1.19': - resolution: {integrity: sha512-FA7n1f6D/DwGE0+AWxiY5LacNbbExQuEgMubeG06idEaH+mSLuf9dp/qBNqOnvbTQ+4gZ2ue1RATF1Ub91Mg5g==} + '@radix-ui/react-alert-dialog@1.1.23': + resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -734,8 +734,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-arrow@1.1.11': - resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -773,8 +773,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-checkbox@1.3.7': - resolution: {integrity: sha512-JroKHfQBfh+fDuzpPsBC+pESkhuq8ql4hljTguz8MWnS35cISr3d/Jhl9kYrB44FlDtxCArYdDvTx+BSsJ64rQ==} + '@radix-ui/react-checkbox@1.3.11': + resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -786,8 +786,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-collection@1.1.12': - resolution: {integrity: sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==} + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -830,6 +830,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context-menu@2.2.16': resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} peerDependencies: @@ -861,8 +870,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-context@1.2.0': - resolution: {integrity: sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==} + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -870,8 +879,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dialog@1.1.19': - resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==} + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -892,8 +901,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-direction@1.1.2': - resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -901,8 +910,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-dismissable-layer@1.1.15': - resolution: {integrity: sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==} + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -914,8 +923,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-dropdown-menu@2.1.20': - resolution: {integrity: sha512-slfm+rRaZRuQBvHq60lXvSVUPhid0IPtjSZzIuUlWZMUs01iYZNlGS3mJgRD3ChLQVBAYlKiL/tFyWGX+dz8Xw==} + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -936,8 +945,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-guards@1.1.4': - resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -945,8 +954,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-focus-scope@1.1.12': - resolution: {integrity: sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==} + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -980,8 +989,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-id@1.1.2': - resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1002,8 +1011,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-menu@2.1.20': - resolution: {integrity: sha512-VsUrXxFe9d2ScbZF0fR/oPR1+qjyeLs5p0jzG8h90puMoA9bq4SirYlXbE+USRg9Q2qTeJSFNqjw2nts8jJe4w==} + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1015,8 +1024,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popover@1.1.19': - resolution: {integrity: sha512-jkrTdQVxnIB8fpn0NyyxW9CTB5aCXZZelVz5z+Xmii6g5WxMqS3fInNslZ63puP39+Puu4jYohUK31y3dT87gQ==} + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1041,8 +1050,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-popper@1.3.3': - resolution: {integrity: sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==} + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1054,8 +1063,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-portal@1.1.13': - resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1080,8 +1089,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1093,8 +1102,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-presence@1.1.7': - resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==} + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1106,8 +1115,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1119,8 +1128,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.5': - resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==} + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1132,8 +1141,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-primitive@2.1.7': - resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} + '@radix-ui/react-primitive@2.1.5': + resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1158,8 +1167,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-roving-focus@1.1.15': - resolution: {integrity: sha512-40svmmugfM3mUN7VUDGVE1tQGOhyi8enlGD0CNJEcMM36C1f71PKM21DFgNHUfem0XnA+d8H8oN3Z9ZpJjSslg==} + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1171,8 +1180,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-separator@1.1.11': - resolution: {integrity: sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==} + '@radix-ui/react-separator@1.1.15': + resolution: {integrity: sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1202,8 +1211,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-slot@1.3.0': - resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1224,8 +1233,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tabs@1.1.17': - resolution: {integrity: sha512-nRyXnrAVCwjeXcHbvEbLS6ndbTeKHG1RqCP4A8Gw5L4cemDzPXdD8rAmr6wet0v57R69wGvuIIsFjHSVkZiMzQ==} + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1237,8 +1246,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-toggle@1.1.14': - resolution: {integrity: sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==} + '@radix-ui/react-toggle@1.1.18': + resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1250,8 +1259,8 @@ packages: '@types/react-dom': optional: true - '@radix-ui/react-tooltip@1.2.12': - resolution: {integrity: sha512-U3HoftgWnmla78vzQbLvKKb7bUYJxoiiqYFzp1wu/TBMyDqMZSuCl3aRICsD6EfVEwcJD2mumGDGUXLFVqQHKA==} + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1281,6 +1290,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-controllable-state@1.2.2': resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} peerDependencies: @@ -1290,8 +1308,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-controllable-state@1.2.3': - resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1308,8 +1326,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-effect-event@0.0.3': - resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1326,6 +1344,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-layout-effect@1.1.1': resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} peerDependencies: @@ -1344,8 +1371,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1353,8 +1380,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-previous@1.1.2': - resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1371,8 +1398,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-rect@1.1.2': - resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1389,8 +1416,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-use-size@1.1.2': - resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1398,8 +1425,8 @@ packages: '@types/react': optional: true - '@radix-ui/react-visually-hidden@1.2.7': - resolution: {integrity: sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==} + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1414,8 +1441,8 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@radix-ui/rect@1.1.2': - resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} @@ -4015,24 +4042,24 @@ snapshots: '@radix-ui/primitive@1.1.3': {} - '@radix-ui/primitive@1.1.5': {} + '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-alert-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dialog': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4061,28 +4088,27 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-checkbox@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-checkbox@1.3.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-collection@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4113,6 +4139,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -4139,26 +4171,27 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-context@1.2.0(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -4173,34 +4206,34 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-dismissable-layer@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-dropdown-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-menu': 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4213,17 +4246,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-focus-scope@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4248,9 +4281,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 @@ -4262,7 +4295,7 @@ snapshots: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-context': 1.1.2(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-direction': 1.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.17)(react@19.2.7) '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@radix-ui/react-id': 1.1.1(@types/react@19.2.17)(react@19.2.7) @@ -4281,24 +4314,24 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-menu@2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -4307,21 +4340,21 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popover@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) aria-hidden: 1.2.6 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -4348,28 +4381,28 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-popper@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/rect': 1.1.2 + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.3 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4386,46 +4419,46 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-primitive@2.1.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-slot': 1.2.5(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4449,28 +4482,28 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-roving-focus@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-collection': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-separator@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-separator@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4491,9 +4524,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 @@ -4513,47 +4546,48 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tabs@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-roving-focus': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-toggle@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-toggle@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@radix-ui/react-tooltip@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': - dependencies: - '@radix-ui/primitive': 1.1.5 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-popper': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-visually-hidden': 1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4572,6 +4606,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.17)(react@19.2.7)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.17)(react@19.2.7) @@ -4580,10 +4620,11 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 @@ -4595,9 +4636,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 @@ -4608,6 +4649,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 @@ -4620,13 +4667,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.17)(react@19.2.7)': dependencies: react: 19.2.7 optionalDependencies: @@ -4639,9 +4686,9 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/rect': 1.1.2 + '@radix-ui/rect': 1.1.3 react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 @@ -4653,16 +4700,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.17)(react@19.2.7)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.7) react: 19.2.7 optionalDependencies: '@types/react': 19.2.17 - '@radix-ui/react-visually-hidden@1.2.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: - '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 react-dom: 19.2.7(react@19.2.7) optionalDependencies: @@ -4671,7 +4718,7 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@radix-ui/rect@1.1.2': {} + '@radix-ui/rect@1.1.3': {} '@rolldown/binding-android-arm64@1.0.3': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 44e39738e0c..632a23bc2b9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,7 +11,7 @@ overrides: # so a modal menu opening a modal dialog left `pointer-events: none` stuck on # when the dialog closed, freezing the app (#1482, reminder dialog). # Removable once every @radix-ui dep converges on one version naturally. - "@radix-ui/react-dismissable-layer": 1.1.15 + "@radix-ui/react-dismissable-layer": 1.1.19 patchedDependencies: isomorphic-git: patches/isomorphic-git.patch virtua@0.49.3: patches/virtua@0.49.3.patch From afb272bb7b8d7d45d7de676fa97dcd5a8eefacc7 Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Mon, 27 Jul 2026 12:22:05 +0200 Subject: [PATCH 11/71] fix(desktop): render rich project work item content (#3100) ## Summary Project issues, pull requests, reviews, and commit details no longer flatten rich content into inert or plain text. They now share the message markdown and media pipeline, preserving NIP-92 `imeta` metadata so links, images, and videos render consistently. Commit bodies are fetched only when a single-commit detail view is opened, keeping list queries lightweight while exposing full context beside the diff. ### Related issue None found. ### Testing - Pre-push suite: `desktop-check`, `desktop-test`, `desktop-tauri-test`, `rust-tests`, and `mobile-test` - Project issue and pull request regression tests cover preserving attachment metadata on roots, updates, and comments - The project commit detail smoke scenario verifies linked text, images, and video in commit bodies Signed-off-by: Thomas Petersen --- .../src/commands/project_git_diff.rs | 34 ++++++++++++++++- .../src/features/forum/ui/ForumPostCard.tsx | 2 +- .../features/forum/ui/ForumThreadPanel.tsx | 2 +- .../messages/lib/imetaMediaMarkdown.ts | 2 +- .../messages/lib/rowHeightEstimate.ts | 2 +- .../messages/lib/timelineImagePreload.ts | 2 +- .../src/features/messages/ui/MessageRow.tsx | 2 +- .../src/features/projects/projectIssues.d.mts | 3 ++ .../src/features/projects/projectIssues.mjs | 6 +++ .../features/projects/projectIssues.test.mjs | 26 +++++++++++++ .../projects/projectPullRequests.d.mts | 3 ++ .../features/projects/projectPullRequests.mjs | 10 ++++- .../projects/projectPullRequests.test.mjs | 38 +++++++++++++++++++ .../projects/ui/ProjectCommitDetailPanel.tsx | 4 ++ .../projects/ui/ProjectIssuesPanel.tsx | 14 ++----- .../ui/ProjectPullRequestInlineComments.tsx | 7 ++-- .../projects/ui/ProjectPullRequestsPanel.tsx | 19 +++++----- .../projects/ui/ProjectRichContent.tsx | 27 +++++++++++++ desktop/src/shared/api/projectGit.ts | 3 ++ desktop/src/shared/api/projectGitTypes.ts | 1 + .../lib => shared/ui/markdown}/parseImeta.ts | 19 +++++----- desktop/src/testing/e2eBridge.ts | 7 ++++ .../tests/e2e/project-commit-detail.spec.ts | 10 +++++ 23 files changed, 200 insertions(+), 43 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectRichContent.tsx rename desktop/src/{features/messages/lib => shared/ui/markdown}/parseImeta.ts (77%) diff --git a/desktop/src-tauri/src/commands/project_git_diff.rs b/desktop/src-tauri/src/commands/project_git_diff.rs index 0bd07cbffdc..400b48eaa51 100644 --- a/desktop/src-tauri/src/commands/project_git_diff.rs +++ b/desktop/src-tauri/src/commands/project_git_diff.rs @@ -25,6 +25,7 @@ pub struct ProjectRepoDiffInfo { pub files: Vec, pub additions: usize, pub deletions: usize, + pub commit_body: Option, } fn clean_target_ref(value: Option) -> Option { @@ -340,7 +341,25 @@ fn diff_from_repo( repo_dir: &std::path::Path, auth: &GitAuthConfig, range: &str, + target_commit: Option<&str>, ) -> Result { + let commit_body = target_commit + .map(|commit| { + run_git( + &[ + "show", + "--no-patch", + "--format=%b", + "--end-of-options", + commit, + ], + Some(repo_dir), + auth, + ) + .map(|body| body.trim_end().to_string()) + }) + .transpose()? + .filter(|body| !body.is_empty()); let numstat = run_git(&["diff", "--numstat", range], Some(repo_dir), auth)?; let files = parse_numstat(&numstat) .into_iter() @@ -375,6 +394,7 @@ fn diff_from_repo( Ok(ProjectRepoDiffInfo { additions: files.iter().map(|file| file.additions).sum(), deletions: files.iter().map(|file| file.deletions).sum(), + commit_body, files, }) } @@ -430,7 +450,12 @@ pub async fn get_project_repo_diff( diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), ), }; - diff_from_repo(&repo_dir, &auth, &range) + let commit_body_ref = if target_ref.is_none() && base_branch.is_none() { + target_commit.as_deref() + } else { + None + }; + diff_from_repo(&repo_dir, &auth, &range, commit_body_ref) }) .await .map_err(|error| format!("repo diff task failed: {error}"))? @@ -468,7 +493,12 @@ pub async fn get_project_local_repo_diff( base_commit.as_deref(), target_commit.as_deref(), ); - diff_from_repo(&repo_dir, &auth, &range).map(Some) + let commit_body_ref = if base_commit.is_none() && base_branch.is_none() { + target_commit.as_deref() + } else { + None + }; + diff_from_repo(&repo_dir, &auth, &range, commit_body_ref).map(Some) }) .await .map_err(|error| format!("local repo diff task failed: {error}"))? diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 311df0f97fd..1fb3c35cc4f 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -9,9 +9,9 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { parseImetaTags } from "@/features/messages/lib/parseImeta"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Markdown } from "@/shared/ui/markdown"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { formatRelativeTime } from "../lib/time"; import { DeleteActionMenu } from "./DeleteActionMenu"; diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 2d32e073da1..c6f1bfa6c17 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -11,9 +11,9 @@ import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; -import { parseImetaTags } from "@/features/messages/lib/parseImeta"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; import { Button } from "@/shared/ui/button"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { Markdown } from "@/shared/ui/markdown"; import { Skeleton } from "@/shared/ui/skeleton"; diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index d988db9dd50..3e16cb332d0 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -26,7 +26,7 @@ */ import type { BlobDescriptor } from "@/shared/api/tauri"; -import { parseImetaTags } from "./parseImeta"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; export type ImetaMedia = BlobDescriptor & { /** Composer-only label used for attachment links; not emitted in imeta. */ diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index 56dd0182759..f2fb268167e 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -3,7 +3,7 @@ import type * as React from "react"; import { dimensionsFromDim } from "@/shared/ui/markdown/utils"; import type { TimelineItem } from "./timelineItems"; import type { TimelineMessage } from "../types"; -import { parseImetaTags } from "./parseImeta"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; /** * Estimate a timeline row's rendered height so its `content-visibility` diff --git a/desktop/src/features/messages/lib/timelineImagePreload.ts b/desktop/src/features/messages/lib/timelineImagePreload.ts index 77320e765a8..41d5b57324a 100644 --- a/desktop/src/features/messages/lib/timelineImagePreload.ts +++ b/desktop/src/features/messages/lib/timelineImagePreload.ts @@ -1,6 +1,6 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import type { TimelineMessage } from "../types"; -import { parseImetaTags } from "./parseImeta"; /** * Return non-message-media image URLs worth warming before a virtualized row diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index a57bf93e407..86038347b76 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -32,7 +32,7 @@ import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; -import { parseImetaTags } from "@/features/messages/lib/parseImeta"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji"; import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage"; import { resolveSnapshotSharedBy } from "@/features/messages/lib/snapshotSharedBy"; diff --git a/desktop/src/features/projects/projectIssues.d.mts b/desktop/src/features/projects/projectIssues.d.mts index b720e902652..b31dc3c1ff7 100644 --- a/desktop/src/features/projects/projectIssues.d.mts +++ b/desktop/src/features/projects/projectIssues.d.mts @@ -11,6 +11,7 @@ export type ProjectIssueStatus = export type ProjectIssueComment = { id: string; content: string; + tags: string[][]; author: string; createdAt: number; }; @@ -19,6 +20,7 @@ export type ProjectIssue = { id: string; title: string; content: string; + tags: string[][]; author: string; createdAt: number; repoAddress: string | null; @@ -41,6 +43,7 @@ export const PROJECT_ISSUE_STATUS: { export function getTag(event: RelayEvent, name: string): string | undefined; export function getAllTags(event: RelayEvent, name: string): string[]; +export function getImetaTags(event: RelayEvent): string[][]; export function eventToProjectIssue( issue: RelayEvent, statusEvents?: RelayEvent[], diff --git a/desktop/src/features/projects/projectIssues.mjs b/desktop/src/features/projects/projectIssues.mjs index 2140050db51..0655245866a 100644 --- a/desktop/src/features/projects/projectIssues.mjs +++ b/desktop/src/features/projects/projectIssues.mjs @@ -22,6 +22,10 @@ export function getAllTags(event, name) { .map((tag) => tag[1]); } +export function getImetaTags(event) { + return event.tags.filter((tag) => tag[0] === "imeta"); +} + function repoOwnerFromAddress(repoAddress) { const owner = (repoAddress ?? "").split(":")[1] ?? ""; return /^[a-fA-F0-9]{64}$/.test(owner) ? owner.toLowerCase() : null; @@ -80,6 +84,7 @@ function commentsForIssue(issueId, commentEvents) { .map((event) => ({ id: event.id, content: event.content, + tags: getImetaTags(event), author: event.pubkey, createdAt: event.created_at, })); @@ -101,6 +106,7 @@ export function eventToProjectIssue( id: issue.id, title, content: issue.content, + tags: getImetaTags(issue), author: issue.pubkey, createdAt: issue.created_at, repoAddress: getTag(issue, "a") ?? null, diff --git a/desktop/src/features/projects/projectIssues.test.mjs b/desktop/src/features/projects/projectIssues.test.mjs index b6fa6a901f0..2d0fb5fb457 100644 --- a/desktop/src/features/projects/projectIssues.test.mjs +++ b/desktop/src/features/projects/projectIssues.test.mjs @@ -99,6 +99,32 @@ test("tag helpers drop malformed value-less tags", () => { assert.equal(issue.title, "Something is broken"); }); +test("preserves root and comment tags for rich content rendering", () => { + const root = issueEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Something is broken"], + ["imeta", "url https://relay.example/media/root.png", "m image/png"], + ], + }); + const comment = { + id: "comment-rich-content", + kind: 1, + pubkey: ATTACKER, + created_at: 200, + content: "![Screenshot](https://relay.example/media/comment.png)", + tags: [ + ["e", root.id, "", "root"], + ["imeta", "url https://relay.example/media/comment.png", "m image/png"], + ], + }; + + const issue = eventToProjectIssue(root, [], [comment]); + + assert.deepEqual(issue.tags, [root.tags[2]]); + assert.deepEqual(issue.comments[0].tags, [comment.tags[1]]); +}); + test("builds repository-scoped issue creation tags", () => { assert.deepEqual( buildGitIssueTags({ diff --git a/desktop/src/features/projects/projectPullRequests.d.mts b/desktop/src/features/projects/projectPullRequests.d.mts index f4d6e0a27dd..af865d2433c 100644 --- a/desktop/src/features/projects/projectPullRequests.d.mts +++ b/desktop/src/features/projects/projectPullRequests.d.mts @@ -3,6 +3,7 @@ import type { RelayEvent } from "@/shared/api/types"; export type ProjectPullRequestUpdate = { id: string; content: string; + tags: string[][]; author: string; createdAt: number; commit: string | null; @@ -12,6 +13,7 @@ export type ProjectPullRequestUpdate = { export type ProjectPullRequestComment = { id: string; content: string; + tags: string[][]; author: string; createdAt: number; commit: string | null; @@ -70,6 +72,7 @@ export type ProjectPullRequest = { id: string; title: string; content: string; + tags: string[][]; author: string; createdAt: number; repoAddress: string | null; diff --git a/desktop/src/features/projects/projectPullRequests.mjs b/desktop/src/features/projects/projectPullRequests.mjs index a5e9b927b7f..3eebaa74f04 100644 --- a/desktop/src/features/projects/projectPullRequests.mjs +++ b/desktop/src/features/projects/projectPullRequests.mjs @@ -1,4 +1,9 @@ -import { allowedActorsForRoot, getAllTags, getTag } from "./projectIssues.mjs"; +import { + allowedActorsForRoot, + getAllTags, + getImetaTags, + getTag, +} from "./projectIssues.mjs"; // Updates and status changes rewrite the PR's tip commit, clone URLs, and // lifecycle state, so they are only honored when signed by the PR author or @@ -135,6 +140,7 @@ function eventToPullRequestUpdate(event) { return { id: event.id, content: event.content, + tags: getImetaTags(event), author: event.pubkey, createdAt: event.created_at, commit: getTag(event, "c") ?? null, @@ -190,6 +196,7 @@ function eventToPullRequestComment(event) { return { id: event.id, content: event.content, + tags: getImetaTags(event), author: event.pubkey, createdAt: event.created_at, commit: getTag(event, "c") ?? null, @@ -352,6 +359,7 @@ export function eventToProjectPullRequest( id: pullRequest.id, title, content: pullRequest.content, + tags: getImetaTags(pullRequest), author: pullRequest.pubkey, createdAt: pullRequest.created_at, repoAddress: getTag(pullRequest, "a") ?? null, diff --git a/desktop/src/features/projects/projectPullRequests.test.mjs b/desktop/src/features/projects/projectPullRequests.test.mjs index b9f2cf98c7f..93746048180 100644 --- a/desktop/src/features/projects/projectPullRequests.test.mjs +++ b/desktop/src/features/projects/projectPullRequests.test.mjs @@ -96,6 +96,44 @@ test("accepts updates signed by the PR author", () => { assert.equal(pullRequest.updateCount, 1); }); +test("preserves root, update, and comment tags for rich content rendering", () => { + const root = pullRequestEvent({ + tags: [ + ["a", REPO_ADDRESS], + ["subject", "Add feature"], + ["c", "1111111111111111111111111111111111111111"], + ["imeta", "url https://relay.example/media/root.png", "m image/png"], + ], + }); + const update = updateEvent({ + pubkey: AUTHOR, + createdAt: 200, + commit: "2222222222222222222222222222222222222222", + }); + update.tags.push([ + "imeta", + "url https://relay.example/media/update.mp4", + "m video/mp4", + ]); + const comment = { + id: "comment-rich-content", + kind: 1, + pubkey: ATTACKER, + created_at: 250, + content: "[Demo](https://relay.example/media/comment.png)", + tags: [ + ["e", root.id, "", "root"], + ["imeta", "url https://relay.example/media/comment.png", "m image/png"], + ], + }; + + const pullRequest = eventToProjectPullRequest(root, [update], [comment]); + + assert.deepEqual(pullRequest.tags, [root.tags[3]]); + assert.deepEqual(pullRequest.updates[0].tags, [update.tags[3]]); + assert.deepEqual(pullRequest.comments[0].tags, [comment.tags[1]]); +}); + test("accepts updates signed by the repo owner", () => { const update = updateEvent({ pubkey: OWNER, diff --git a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx index e20ebe4eccc..c6122dcfe4e 100644 --- a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx @@ -12,6 +12,7 @@ import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types"; import { CopyCommitHashButton } from "./ProjectCommitCopyButton"; import { ProfileIdentityButton } from "./ProjectProfileIdentity"; import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel"; +import { ProjectRichContent } from "./ProjectRichContent"; function commitDateLabel(timestamp: number) { return new Date(timestamp * 1_000).toLocaleString(undefined, { @@ -98,6 +99,9 @@ export function ProjectCommitDetailPanel({ + {diff?.commitBody ? ( + + ) : null} {issue.content ? ( - + ) : null} @@ -238,11 +234,7 @@ function IssueDetail({ role={relativeTime(item.createdAt)} /> - + ))} diff --git a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx index e3170b36050..281aefe71be 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestInlineComments.tsx @@ -8,7 +8,7 @@ import type { import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { Markdown } from "@/shared/ui/markdown"; +import { ProjectRichContent } from "./ProjectRichContent"; function commentAuthor( pubkey: string, @@ -68,10 +68,9 @@ export function ProjectPullRequestInlineCommentThread({ {relativeTime(comment.createdAt)} - ))} diff --git a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx index bc07969d740..4c14309f24a 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestsPanel.tsx @@ -36,7 +36,6 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { useIdentityQuery } from "@/shared/api/hooks"; import type { ChannelMember } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; -import { Markdown } from "@/shared/ui/markdown"; import { ProjectFeedRow, ProjectFeedRowCluster, @@ -49,6 +48,7 @@ import { ProfileAuthorName, ProfileIdentityButton, } from "./ProjectProfileIdentity"; +import { ProjectRichContent } from "./ProjectRichContent"; import { PullRequestReviewersRow } from "./PullRequestReviewersRow"; import { PullRequestReviewCard } from "./PullRequestReviewCard"; @@ -639,10 +639,9 @@ function PullRequestDetail({
{pullRequest.content ? (
-
) : null} @@ -670,9 +669,11 @@ function PullRequestDetail({ ) : null}
{update.content ? ( -

- {update.content} -

+ ) : null} ))} @@ -829,10 +830,10 @@ function PullRequestDetail({ {activityContent ? ( - ) : null} {item.anchor ? ( diff --git a/desktop/src/features/projects/ui/ProjectRichContent.tsx b/desktop/src/features/projects/ui/ProjectRichContent.tsx new file mode 100644 index 00000000000..d6e61b4d563 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectRichContent.tsx @@ -0,0 +1,27 @@ +import * as React from "react"; + +import { Markdown } from "@/shared/ui/markdown"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; + +/** + * Renders project event content with the same link and media support as + * messages while retaining NIP-92 attachment metadata from the source event. + */ +export function ProjectRichContent({ + className = "text-sm", + content, + tags, +}: { + className?: string; + content: string; + tags?: string[][]; +}) { + const imetaByUrl = React.useMemo( + () => (tags ? parseImetaTags(tags) : undefined), + [tags], + ); + + return ( + + ); +} diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts index db5f31c458b..7293db19385 100644 --- a/desktop/src/shared/api/projectGit.ts +++ b/desktop/src/shared/api/projectGit.ts @@ -116,6 +116,7 @@ type RawProjectRepoDiff = { files: RawProjectRepoDiffFile[]; additions: number; deletions: number; + commit_body: string | null; }; function fromRawProjectRepoSnapshot( @@ -192,6 +193,7 @@ export async function getProjectRepoDiff(input: { return { additions: diff.additions, deletions: diff.deletions, + commitBody: diff.commit_body, files: diff.files.map((file) => ({ path: file.path, additions: file.additions, @@ -227,6 +229,7 @@ export async function getProjectLocalRepoDiff(input: { return { additions: diff.additions, deletions: diff.deletions, + commitBody: diff.commit_body, files: diff.files.map((file) => ({ path: file.path, additions: file.additions, diff --git a/desktop/src/shared/api/projectGitTypes.ts b/desktop/src/shared/api/projectGitTypes.ts index 44567e99e45..46854eb33e7 100644 --- a/desktop/src/shared/api/projectGitTypes.ts +++ b/desktop/src/shared/api/projectGitTypes.ts @@ -43,6 +43,7 @@ export type ProjectRepoDiff = { files: ProjectRepoDiffFile[]; additions: number; deletions: number; + commitBody: string | null; }; export type ProjectLocalRepoSnapshot = { diff --git a/desktop/src/features/messages/lib/parseImeta.ts b/desktop/src/shared/ui/markdown/parseImeta.ts similarity index 77% rename from desktop/src/features/messages/lib/parseImeta.ts rename to desktop/src/shared/ui/markdown/parseImeta.ts index 72d28fccc23..9be060f7ae6 100644 --- a/desktop/src/features/messages/lib/parseImeta.ts +++ b/desktop/src/shared/ui/markdown/parseImeta.ts @@ -1,22 +1,21 @@ -export type ImetaEntry = { +import type { ImetaEntry } from "./types"; + +export type ParsedImetaEntry = ImetaEntry & { url: string; m: string; x: string; size: number; - dim?: string; blurhash?: string; alt?: string; - thumb?: string; - duration?: number; - image?: string; - filename?: string; }; -export function parseImetaTags(tags: string[][]): Map { - const map = new Map(); +export function parseImetaTags( + tags: string[][], +): Map { + const map = new Map(); for (const tag of tags) { if (tag[0] !== "imeta") continue; - const entry: Partial = {}; + const entry: Partial = {}; for (const part of tag.slice(1)) { const spaceIdx = part.indexOf(" "); if (spaceIdx === -1) continue; @@ -58,7 +57,7 @@ export function parseImetaTags(tags: string[][]): Map { break; } } - if (entry.url) map.set(entry.url, entry as ImetaEntry); + if (entry.url) map.set(entry.url, entry as ParsedImetaEntry); } return map; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4c085a05338..493908c872d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9660,6 +9660,13 @@ export function maybeInstallE2eTauriMocks() { return { additions: 27, deletions: 4, + commit_body: [ + "See the [project guide](https://example.com/project-guide).", + "", + "![Architecture](/buzz.svg)", + "", + "![Demo](https://example.com/project-demo.mp4)", + ].join("\n"), files: [ { path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 52c886a0d1c..32cd807552a 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -187,6 +187,16 @@ test("commit detail opens from the commits feed with a diff", async ({ await expect( page.getByRole("button", { name: "Copy commit hash" }), ).toBeVisible(); + await expect( + page.getByRole("link", { name: "project guide" }), + ).toHaveAttribute("href", "https://example.com/project-guide"); + await expect( + page.getByRole("button", { name: "Architecture" }), + ).toBeVisible(); + await expect(page.locator("video")).toHaveAttribute( + "src", + "https://example.com/project-demo.mp4", + ); // Diff from the mocked get_project_repo_diff renders changed files. await expect(page.getByText("2 changed files")).toBeVisible({ From 37420764349bcd8f3dcf34786c30a8f924152922 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 27 Jul 2026 04:16:33 -0700 Subject: [PATCH 12/71] fix(mobile): mitigate message-post delay with optimistic rendering (#3037) ### What changed? Adds optimistic rendering for newly locally posted messages in channels and threads. - Messages appear immediately - Relay echoes and history are deduplicated by event ID - Rejected or timed-out publishes erase the optimistic rendering. The implementation covers reconnect and hydration races, channel-window and legacy WebSocket paths, thread-local overlays, and rapid concurrent sends. ### Why? This is a valuable partial mitigation for [BOT-1449](https://linear.app/squareup/issue/BOT-1449/buzz-mobile-posted-messages-dont-appear-until-leavingre-entering-the): senders no longer depend on receiving a relay echo before seeing their own post. It does not address the likely primary cause of stale channels. Mobile currently does not recover live subscriptions after a rate-limited relay `CLOSED`; that recovery is being handled separately. ### How is it tested? Full mobile suite: 676 passed, 1 skipped. Added regression coverage for optimistic insertion, authoritative deduplication, rollback, reconnect and hydration, thread replies, rapid and equal-time sends, never-echoed successful sends, and legacy WebSocket retirement. --------- Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- .../channels/channel_messages_provider.dart | 101 ++++- .../pending_local_messages_provider.dart | 42 ++ .../channels/send_message_provider.dart | 45 ++- .../features/channels/thread_detail_page.dart | 2 +- .../channels/thread_replies_provider.dart | 77 ++++ .../lib/shared/relay/signed_event_relay.dart | 2 + .../channel_messages_provider_test.dart | 380 +++++++++++++++++- .../read_state/read_state_manager_test.dart | 3 + .../channels/send_message_provider_test.dart | 96 +++++ 9 files changed, 737 insertions(+), 11 deletions(-) create mode 100644 mobile/lib/features/channels/pending_local_messages_provider.dart create mode 100644 mobile/test/features/channels/send_message_provider_test.dart diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 5d1964f8146..fb910f069ae 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import 'channel_management_provider.dart'; +import 'pending_local_messages_provider.dart'; import 'channel_window.dart'; import 'thread_replies_provider.dart'; @@ -87,6 +88,7 @@ class ChannelMessagesNotifier extends Notifier>> { final history = await _fetchNewestHistory(session); if (!_isCurrentInit(initVersion)) return; + _confirmLocalMessages(history.map((event) => event.id)); final existing = state.value ?? const []; final existingIds = existing.map((event) => event.id).toSet(); @@ -159,7 +161,13 @@ class ChannelMessagesNotifier extends Notifier>> { }, ); - void _handleLiveEvent(NostrEvent event) { + void _handleLiveEvent(NostrEvent event, {bool authoritative = true}) { + // Reply ownership and its thread-local overlay must transition together. + // The authoritative thread query performs both confirmations after it + // contains the reply; a live echo only triggers that query below. + if (authoritative && event.threadReference.parentId == null) { + _confirmLocalMessages([event.id]); + } if (_usingChannelWindow) { _handleWindowLiveEvent(event); } else { @@ -223,19 +231,108 @@ class ChannelMessagesNotifier extends Notifier>> { return true; } + void _confirmLocalMessages(Iterable eventIds) { + ref + .read(pendingLocalMessagesProvider(channelId).notifier) + .confirm(eventIds); + } + static bool _isMembershipEvent(String content) { return content.contains('member_joined') || content.contains('member_left') || content.contains('member_removed'); } + /// Adds a just-signed outgoing message before the relay acknowledges it. + /// The live relay echo is deduplicated by event id. + void addLocalMessage(NostrEvent event) { + ref.read(pendingLocalMessagesProvider(channelId).notifier).add(event); + final thread = event.threadReference; + if (thread.parentId != null) { + final rootId = thread.rootId; + if (rootId == null) { + throw StateError('Reply ${event.id} has a parent but no thread root.'); + } + ref + .read( + threadLocalRepliesProvider( + ThreadRepliesArgs(channelId: channelId, rootId: rootId), + ).notifier, + ) + .add(event); + return; + } + + final isTimelineRow = EventKind.channelTimelineContentKinds.contains( + event.kind, + ); + if (!_usingChannelWindow && isTimelineRow) { + _windowStore = mergeLiveChannelWindowEvent( + _windowStore, + event, + isTimelineRow: true, + ); + } + _handleLiveEvent(event, authoritative: false); + } + + /// Releases rollback ownership after the publish future succeeds. The + /// optimistic row (and any thread overlay) remains visible until relay data + /// replaces it, because OK and EVENT delivery are unordered. + void completeLocalMessage(String eventId) { + _confirmLocalMessages([eventId]); + } + + /// Rolls back a local message when its publish is rejected or times out. + void removeLocalMessage(String eventId) { + final pending = ref + .read(pendingLocalMessagesProvider(channelId).notifier) + .take(eventId); + if (pending == null) return; + + final thread = pending.threadReference; + if (thread.parentId != null) { + final rootId = thread.rootId; + if (rootId == null) { + throw StateError('Reply $eventId has a parent but no thread root.'); + } + ref + .read( + threadLocalRepliesProvider( + ThreadRepliesArgs(channelId: channelId, rootId: rootId), + ).notifier, + ) + .remove(eventId); + return; + } + + final nextOverlay = _windowStore.liveOverlay + .where((event) => event.id != eventId) + .toList(); + if (nextOverlay.length != _windowStore.liveOverlay.length) { + _windowStore = ChannelWindowStore( + pages: _windowStore.pages, + liveOverlay: nextOverlay, + liveAux: _windowStore.liveAux, + ); + } + + final current = state.value ?? _lastKnownMessages ?? const []; + final next = current.where((event) => event.id != eventId).toList(); + _lastKnownMessages = next; + state = AsyncData(next); + } + static List _mergeEvent( List current, NostrEvent incoming, ) { if (current.any((e) => e.id == incoming.id)) return current; final updated = [...current, incoming]; - updated.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + updated.sort((a, b) { + final createdAt = a.createdAt.compareTo(b.createdAt); + return createdAt != 0 ? createdAt : a.id.compareTo(b.id); + }); return updated; } diff --git a/mobile/lib/features/channels/pending_local_messages_provider.dart b/mobile/lib/features/channels/pending_local_messages_provider.dart new file mode 100644 index 00000000000..064b862022c --- /dev/null +++ b/mobile/lib/features/channels/pending_local_messages_provider.dart @@ -0,0 +1,42 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; + +/// Signed local messages whose publish has not yet been corroborated by an +/// authoritative relay EVENT or query result. +class PendingLocalMessagesNotifier extends Notifier> { + final String channelId; + + PendingLocalMessagesNotifier(this.channelId); + + @override + Map build() => const {}; + + void add(NostrEvent event) { + state = {...state, event.id: event}; + } + + NostrEvent? take(String eventId) { + final event = state[eventId]; + if (event == null) return null; + final next = {...state}..remove(eventId); + state = next; + return event; + } + + void confirm(Iterable eventIds) { + final confirmed = eventIds.toSet(); + if (!state.keys.any(confirmed.contains)) return; + state = { + for (final entry in state.entries) + if (!confirmed.contains(entry.key)) entry.key: entry.value, + }; + } +} + +final pendingLocalMessagesProvider = + NotifierProvider.family< + PendingLocalMessagesNotifier, + Map, + String + >(PendingLocalMessagesNotifier.new); diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 1771b05f12a..3659bda4bf0 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -4,6 +4,7 @@ import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'channel_messages_provider.dart'; /// Sends messages by signing an event with the user's nsec and publishing it /// over the relay's NIP-42-authenticated WebSocket session. @@ -11,15 +12,25 @@ class SendMessage { final SignedEventRelay _signedEventRelay; final Future> Function(String channelId) _fetchMembers; final Map Function() _readUserCache; + final void Function(String channelId, NostrEvent event) _addLocalMessage; + final void Function(String channelId, String eventId) _completeLocalMessage; + final void Function(String channelId, String eventId) _removeLocalMessage; SendMessage({ required SignedEventRelay signedEventRelay, required Future> Function(String channelId) fetchMembers, required Map Function() readUserCache, + required void Function(String channelId, NostrEvent event) addLocalMessage, + required void Function(String channelId, String eventId) + completeLocalMessage, + required void Function(String channelId, String eventId) removeLocalMessage, }) : _signedEventRelay = signedEventRelay, _fetchMembers = fetchMembers, - _readUserCache = readUserCache; + _readUserCache = readUserCache, + _addLocalMessage = addLocalMessage, + _completeLocalMessage = completeLocalMessage, + _removeLocalMessage = removeLocalMessage; /// Send a text message to a channel. /// @@ -58,11 +69,24 @@ class SendMessage { ...mediaTags, ]; - await _signedEventRelay.submit( - kind: EventKind.streamMessage, - content: content, - tags: tags, - ); + NostrEvent? localMessage; + try { + await _signedEventRelay.submit( + kind: EventKind.streamMessage, + content: content, + tags: tags, + onSigned: (event) { + localMessage = event; + _addLocalMessage(channelId, event); + }, + ); + final event = localMessage; + if (event != null) _completeLocalMessage(channelId, event.id); + } catch (_) { + final event = localMessage; + if (event != null) _removeLocalMessage(channelId, event.id); + rethrow; + } } /// Resolve @mentions to pubkeys, scoped to channel members. @@ -146,5 +170,14 @@ final sendMessageProvider = Provider((ref) { fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), readUserCache: () => ref.read(userCacheProvider), + addLocalMessage: (channelId, event) => ref + .read(channelMessagesProvider(channelId).notifier) + .addLocalMessage(event), + completeLocalMessage: (channelId, eventId) => ref + .read(channelMessagesProvider(channelId).notifier) + .completeLocalMessage(eventId), + removeLocalMessage: (channelId, eventId) => ref + .read(channelMessagesProvider(channelId).notifier) + .removeLocalMessage(eventId), ); }); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 94e95b8fe59..a81de36e049 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -54,7 +54,7 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final repliesState = ref.watch( - threadRepliesProvider( + threadRepliesWithLocalProvider( ThreadRepliesArgs(channelId: channelId, rootId: threadHead.id), ), ); diff --git a/mobile/lib/features/channels/thread_replies_provider.dart b/mobile/lib/features/channels/thread_replies_provider.dart index 0fa2b15bd1e..6904ba8c5e8 100644 --- a/mobile/lib/features/channels/thread_replies_provider.dart +++ b/mobile/lib/features/channels/thread_replies_provider.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import 'pending_local_messages_provider.dart'; class ThreadRepliesArgs { final String channelId; @@ -64,3 +67,77 @@ NostrFilter _threadRepliesFilter( }, ); } + +class ThreadLocalRepliesNotifier extends Notifier> { + final ThreadRepliesArgs args; + + ThreadLocalRepliesNotifier(this.args); + + @override + List build() => const []; + + void add(NostrEvent event) { + state = _mergeReplies(state, [event]); + } + + void remove(String eventId) { + state = state.where((event) => event.id != eventId).toList(); + } + + void confirm(Set eventIds) { + if (!state.any((event) => eventIds.contains(event.id))) return; + state = state.where((event) => !eventIds.contains(event.id)).toList(); + } +} + +final threadLocalRepliesProvider = + NotifierProvider.family< + ThreadLocalRepliesNotifier, + List, + ThreadRepliesArgs + >(ThreadLocalRepliesNotifier.new); + +/// Relay-backed replies merged with signed local replies that are still +/// waiting for acknowledgement. +final threadRepliesWithLocalProvider = + Provider.family>, ThreadRepliesArgs>(( + ref, + args, + ) { + final relayReplies = ref.watch(threadRepliesProvider(args)); + final localReplies = ref.watch(threadLocalRepliesProvider(args)); + final authoritative = relayReplies.value; + if (authoritative != null && localReplies.isNotEmpty) { + final authoritativeIds = authoritative.map((event) => event.id).toSet(); + if (localReplies.any((event) => authoritativeIds.contains(event.id))) { + Future.microtask(() { + ref + .read(threadLocalRepliesProvider(args).notifier) + .confirm(authoritativeIds); + ref + .read(pendingLocalMessagesProvider(args.channelId).notifier) + .confirm(authoritativeIds); + }); + } + } + if (localReplies.isEmpty) return relayReplies; + return relayReplies.when( + data: (events) => AsyncData(_mergeReplies(events, localReplies)), + loading: () => AsyncData(localReplies), + error: (error, stackTrace) => AsyncData(localReplies), + ); + }); + +List _mergeReplies( + Iterable first, + Iterable second, +) { + final byId = {}; + for (final event in [...first, ...second]) { + byId[event.id] = event; + } + return byId.values.toList()..sort((a, b) { + final createdAt = a.createdAt.compareTo(b.createdAt); + return createdAt != 0 ? createdAt : a.id.compareTo(b.id); + }); +} diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index 73000952238..a739b765941 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -31,6 +31,7 @@ class SignedEventRelay { required String content, required List> tags, int? createdAt, + void Function(NostrEvent event)? onSigned, }) async { final nsec = _nsec; if (nsec == null || nsec.isEmpty) { @@ -52,6 +53,7 @@ class SignedEventRelay { ); final nostrEvent = NostrEvent.fromJson(event.toMap()); + onSigned?.call(nostrEvent); return _session.publish(nostrEvent); } } diff --git a/mobile/test/features/channels/channel_messages_provider_test.dart b/mobile/test/features/channels/channel_messages_provider_test.dart index 8885b55e168..6620c8c506c 100644 --- a/mobile/test/features/channels/channel_messages_provider_test.dart +++ b/mobile/test/features/channels/channel_messages_provider_test.dart @@ -5,6 +5,8 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/features/channels/channel_messages_provider.dart'; +import 'package:buzz/features/channels/pending_local_messages_provider.dart'; +import 'package:buzz/features/channels/thread_replies_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; void main() { @@ -130,6 +132,369 @@ void main() { }, ); + test( + 'adds and rolls back a local message in the websocket timeline', + () async { + final relaySession = _RecordingRelaySessionNotifier(); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + + notifier.addLocalMessage(_event(id: 'local', createdAt: 20)); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['local'], + ); + + relaySession.completeHistory([_event(id: 'history', createdAt: 10)]); + await _pumpEventQueue(); + + // The initial history merge must retain a local row even if the relay's + // history snapshot was taken before that outgoing event was durable. + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history', 'local'], + ); + + notifier.removeLocalMessage('local'); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history'], + ); + }, + ); + + test( + 'legacy websocket echo retires ownership without duplicating the row', + () async { + final relaySession = _RecordingRelaySessionNotifier(); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final local = _event(id: 'local', createdAt: 20); + notifier.addLocalMessage(local); + + relaySession.emit(local); + await _pumpEventQueue(); + + expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['local'], + ); + }, + ); + + test('adds and rolls back a local message in the channel window', () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'history', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + + notifier.addLocalMessage(_event(id: 'local', createdAt: 20)); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history', 'local'], + ); + + notifier.removeLocalMessage('local'); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history'], + ); + }); + + test('reconnect hydration cannot retain a rolled-back local row', () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'history', createdAt: 10), _bounds()], + [_event(id: 'history', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.addLocalMessage(_event(id: 'local', createdAt: 20)); + + relaySession.setConnected(false); + await _pumpEventQueue(); + relaySession.setConnected(true); + await _pumpEventQueue(); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history', 'local'], + ); + + notifier.removeLocalMessage('local'); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history'], + ); + }); + + test( + 'thread replies are inserted, deduped, and rolled back locally', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'history', createdAt: 10), _bounds()], + [], + [ + _event( + id: 'reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ), + ], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.read(threadRepliesWithLocalProvider(args)); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final reply = _event( + id: 'reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + + notifier.addLocalMessage(reply); + expect( + container + .read(threadRepliesWithLocalProvider(args)) + .value + ?.map((event) => event.id), + ['reply'], + ); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history'], + ); + + relaySession.emit(reply); + await container.read(threadRepliesProvider(args).future); + container.read(threadRepliesWithLocalProvider(args)); + await _pumpEventQueue(); + expect( + container + .read(threadRepliesWithLocalProvider(args)) + .value + ?.map((event) => event.id), + ['reply'], + ); + expect(container.read(threadLocalRepliesProvider(args)), isEmpty); + expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty); + + final rejected = _event( + id: 'rejected', + createdAt: 21, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + notifier.addLocalMessage(rejected); + notifier.removeLocalMessage('rejected'); + expect( + container + .read(threadRepliesWithLocalProvider(args)) + .value + ?.map((event) => event.id), + ['reply'], + ); + }, + ); + + test( + 'thread live echo keeps ownership until the authoritative refetch succeeds', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'history', createdAt: 10), _bounds()], + [], + Exception('thread refetch failed'), + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + const args = ThreadRepliesArgs(channelId: _channelId, rootId: 'root'); + container.read(threadRepliesWithLocalProvider(args)); + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + final reply = _event( + id: 'reply', + createdAt: 20, + extraTags: const [ + ['e', 'root', '', 'reply'], + ], + ); + notifier.addLocalMessage(reply); + + relaySession.emit(reply); + await _pumpEventQueue(); + + expect(container.read(pendingLocalMessagesProvider(_channelId)).keys, [ + 'reply', + ]); + expect( + container + .read(threadLocalRepliesProvider(args)) + .map((event) => event.id), + ['reply'], + ); + expect( + container + .read(threadRepliesWithLocalProvider(args)) + .value + ?.map((event) => event.id), + ['reply'], + ); + }, + ); + + test( + 'successful never-echoed send releases ownership but keeps its row across reconnect', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'history', createdAt: 10), _bounds()], + [_event(id: 'history', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.addLocalMessage(_event(id: 'local', createdAt: 20)); + notifier.completeLocalMessage('local'); + + expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty); + relaySession.setConnected(false); + await _pumpEventQueue(); + relaySession.setConnected(true); + await _pumpEventQueue(); + + expect(container.read(pendingLocalMessagesProvider(_channelId)), isEmpty); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history', 'local'], + ); + }, + ); + + test( + 'window dedupes echoes and orders rapid equal-time local sends', + () async { + final relaySession = _RecordingRelaySessionNotifier( + queryResults: [ + [_event(id: 'history', createdAt: 10), _bounds()], + ], + ); + final container = _buildContainer(relaySession); + addTearDown(container.dispose); + + container.read(channelMessagesProvider(_channelId)); + await relaySession.subscribed; + await _pumpEventQueue(); + final notifier = container.read( + channelMessagesProvider(_channelId).notifier, + ); + notifier.addLocalMessage(_event(id: 'z-local', createdAt: 20)); + notifier.addLocalMessage(_event(id: 'a-local', createdAt: 20)); + relaySession.emit(_event(id: 'z-local', createdAt: 20)); + await _pumpEventQueue(); + + expect(container.read(pendingLocalMessagesProvider(_channelId)).keys, [ + 'a-local', + ]); + expect( + container + .read(channelMessagesProvider(_channelId)) + .value + ?.map((event) => event.id), + ['history', 'z-local', 'a-local'], + ); + }, + ); + test('window pagination failures return false without exhausting', () async { final relaySession = _RecordingRelaySessionNotifier( queryResults: [ @@ -178,14 +543,19 @@ ProviderContainer _buildContainer(_RecordingRelaySessionNotifier relaySession) { ); } -NostrEvent _event({required String id, required int createdAt}) { +NostrEvent _event({ + required String id, + required int createdAt, + List> extraTags = const [], +}) { return NostrEvent( id: id, pubkey: 'alice', createdAt: createdAt, kind: EventKind.streamMessageV2, - tags: const [ + tags: [ ['h', _channelId], + ...extraTags, ], content: id, sig: 'sig', @@ -243,6 +613,12 @@ class _RecordingRelaySessionNotifier extends RelaySessionNotifier { @override SessionState build() => const SessionState(status: SessionStatus.connected); + void setConnected(bool connected) { + state = SessionState( + status: connected ? SessionStatus.connected : SessionStatus.disconnected, + ); + } + @override Future> queryRelay( List filters, { diff --git a/mobile/test/features/channels/read_state/read_state_manager_test.dart b/mobile/test/features/channels/read_state/read_state_manager_test.dart index e4240a7c145..953d2ff10d7 100644 --- a/mobile/test/features/channels/read_state/read_state_manager_test.dart +++ b/mobile/test/features/channels/read_state/read_state_manager_test.dart @@ -181,6 +181,7 @@ class _FakeSignedEventRelay implements SignedEventRelay { required String content, required List> tags, int? createdAt, + void Function(NostrEvent event)? onSigned, }) async { submitted.complete(_SubmittedEvent(kind: kind, tags: tags)); return _stubAckEvent(); @@ -199,6 +200,7 @@ class _UnsupportedKindSignedEventRelay implements SignedEventRelay { required String content, required List> tags, int? createdAt, + void Function(NostrEvent event)? onSigned, }) async { submitCount++; throw Exception('restricted: unknown event kind'); @@ -217,6 +219,7 @@ class _MissingScopeSignedEventRelay implements SignedEventRelay { required String content, required List> tags, int? createdAt, + void Function(NostrEvent event)? onSigned, }) async { submitCount++; throw Exception('missing users:write'); diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart new file mode 100644 index 00000000000..f91ce87b2b0 --- /dev/null +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/send_message_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; + +void main() { + test( + 'adds the signed message locally before relay acknowledgement', + () async { + final session = _PendingPublishRelaySession(); + final localMessages = []; + final removedIds = []; + final completedIds = []; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + addLocalMessage: (_, event) => localMessages.add(event), + completeLocalMessage: (_, eventId) => completedIds.add(eventId), + removeLocalMessage: (_, eventId) => removedIds.add(eventId), + ); + + final result = send(channelId: _channelId, content: 'hello'); + await session.published; + + expect(localMessages, hasLength(1)); + expect(localMessages.single.id, session.event.id); + expect(localMessages.single.content, 'hello'); + expect(localMessages.single.channelId, _channelId); + expect(removedIds, isEmpty); + + session.accept(); + await result; + expect(completedIds, [localMessages.single.id]); + expect(removedIds, isEmpty); + }, + ); + + test('rolls back the signed local message when publish fails', () async { + final session = _PendingPublishRelaySession(); + final localMessages = []; + final completedIds = []; + final removedIds = []; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + addLocalMessage: (_, event) => localMessages.add(event), + completeLocalMessage: (_, eventId) => completedIds.add(eventId), + removeLocalMessage: (_, eventId) => removedIds.add(eventId), + ); + + final result = send(channelId: _channelId, content: 'hello'); + await session.published; + session.reject(); + + await expectLater(result, throwsException); + expect(completedIds, isEmpty); + expect(removedIds, [localMessages.single.id]); + }); +} + +const _channelId = '11111111-1111-4111-8111-111111111111'; + +class _PendingPublishRelaySession extends RelaySessionNotifier { + final Completer _result = Completer(); + final Completer _published = Completer(); + late NostrEvent event; + + Future get published => _published.future; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) { + this.event = event; + _published.complete(); + return _result.future; + } + + void accept() => _result.complete(event); + + void reject() => _result.completeError(Exception('relay rejected event')); +} From 7fc0cc82db4d9dced9c258bbe8b530164a832a77 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 27 Jul 2026 04:56:40 -0700 Subject: [PATCH 13/71] Restore Goose and Buzz Agent to onboarding harness selection (#2731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > **Part 1 of a multi-PR onboarding rework.** This PR only restores the hidden harnesses, adjusts the card layout, and gates onboarding completion on valid config. A follow-up PR (stacked on this branch) restructures the flow: the harness page becomes a single-choice "pick your default harness" step, and install/sign-in/provider setup moves to the following page. Review this one on its own terms — the flow rework is intentionally not here. ## Summary - Restore **Goose** and **Buzz Agent** to onboarding harness selection, reverting the launch-only restriction from #2233 - The restore is the single centralized allowlist (`ONBOARDING_RUNTIME_ORDER` in `onboardingRuntimeSelection.ts`) that #2233 deliberately set up for this moment — setup cards, readiness handoff, and the defaults harness picker all derive from it - Lay the four harness cards out as a single row at `lg` and above (`lg:grid-cols-4`); below 1024px (including the app's 800px minimum window width) the grid is 2×2, and 1-up on narrow viewports - **Gate onboarding Finish on actual config validity** (review finding): the defaults page rendered provider/model/credential fields for provider-required harnesses but Finish only checked that a harness was selected — a fresh user picking Buzz Agent could persist a default that fails at first spawn. The Finish gate now consumes `AgentConfigFields`' existing `onValidityChange` signal. Baked build env and runtime-file config satisfy the gate, so internal builds and existing Goose users are never blocked - Update the unit + E2E specs that pinned the hidden behavior, plus two new E2E cases pinning the Finish gate (blocked-until-configured, and baked-env never blocked) - AGENTS.md rule 7 updated to document the completion gate ## Testing - `onboardingRuntimeSelection.test.mjs` — 4 passed - `pnpm typecheck` — clean - `pnpm exec playwright test tests/e2e/onboarding-agent-defaults.spec.ts --project=smoke` — 21 passed - `onboarding-docked-cta-screenshots.spec.ts` — 3 passed - Biome — clean ## Known cosmetic issue (deferred to PR 2) At the app's minimum window size (800×500) the 2×2 grid extends past the visible area and the footer CTA overlaps card space. Next still hit-tests correctly. PR 2 redesigns this page entirely (cards become a single-choice chooser with no inline setup), so this is deferred rather than patched twice. --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- desktop/src/features/agents/AGENTS.md | 6 +- .../onboarding/ui/DefaultConfigStep.tsx | 15 ++- .../src/features/onboarding/ui/SetupStep.tsx | 2 +- .../ui/onboardingRuntimeSelection.test.mjs | 13 ++- .../ui/onboardingRuntimeSelection.ts | 7 +- .../e2e/onboarding-agent-defaults.spec.ts | 104 ++++++++++++++++-- 6 files changed, 125 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2af9ddb98c2..06e6c02acbb 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -67,7 +67,11 @@ with a TypeScript lookup table or an id comparison in a component. 7. **Onboarding setup detects readiness; it does not select defaults.** The setup page derives visible and ready harnesses from the runtime catalog and only offers install or sign-in actions. The following defaults page is the - sole onboarding surface that chooses and persists `preferred_runtime`. + sole onboarding surface that chooses and persists `preferred_runtime`, and + its Finish gate consumes the shared renderer's `onValidityChange` signal — + a harness selection alone does not complete onboarding when the harness + requires provider/model/credential config (e.g. buzz-agent with no + provider). Baked build env and runtime-file config satisfy the gate. `onboarding-agent-defaults.spec.ts` is the acceptance gate for anything touching this flow or the shared renderer. 8. **Omit the Model control only after a confirmed successful empty diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 90f8b22d629..50887f08aa7 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -68,6 +68,7 @@ function AgentDefaultsSection({ cancel: () => void; } | null>(null); const [isSaving, setIsSaving] = React.useState(false); + const [configIsValid, setConfigIsValid] = React.useState(false); React.useEffect(() => { let unmounted = false; @@ -187,10 +188,19 @@ function AgentDefaultsSection({ ); React.useEffect(() => { onPersistenceStateChange({ - canComplete: selectedRuntimeId.length > 0 && !isSaving, + // configIsValid comes from AgentConfigFields' onValidityChange and + // covers model + provider credentials — a harness selection alone is + // not a working default (e.g. buzz-agent with no provider configured). + canComplete: selectedRuntimeId.length > 0 && configIsValid && !isSaving, flush: flushPersistence, }); - }, [flushPersistence, isSaving, onPersistenceStateChange, selectedRuntimeId]); + }, [ + configIsValid, + flushPersistence, + isSaving, + onPersistenceStateChange, + selectedRuntimeId, + ]); return (
@@ -239,6 +249,7 @@ function AgentDefaultsSection({ }} onCustomModelEditingChange={setIsCustomModelEditing} onIsCustomProviderChange={setIsCustomProvider} + onValidityChange={setConfigIsValid} placeholderClassName="text-foreground/70" runtimeFileConfig={runtimeFileConfig} selectClassName="h-12 rounded-2xl border-foreground/15 bg-white px-4 py-2 text-sm shadow-none hover:bg-white/95" diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index c66e6e80c1c..b6a6a5dd57b 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -615,7 +615,7 @@ function RuntimeProvidersSection({
{orderedItems.length > 0 ? ( -
+
{orderedItems.map((runtime) => ( { +test("all bundled harnesses are visible in onboarding", () => { assert.equal(runtimeIsVisibleInOnboarding("claude"), true); assert.equal(runtimeIsVisibleInOnboarding("codex"), true); - assert.equal(runtimeIsVisibleInOnboarding("goose"), false); - assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), false); + assert.equal(runtimeIsVisibleInOnboarding("goose"), true); + assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), true); assert.equal(runtimeIsVisibleInOnboarding("custom"), false); }); @@ -30,7 +30,7 @@ test("visible onboarding runtimes use the product order", () => { assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex"], + ["claude", "codex", "goose", "buzz-agent"], ); }); @@ -55,16 +55,17 @@ test("readiness requires an available and authenticated runtime", () => { ); }); -test("ready onboarding runtimes exclude hidden ready harnesses", () => { +test("ready onboarding runtimes exclude unknown and non-ready harnesses", () => { const runtimes = [ runtime("goose", "available", "not_applicable"), runtime("codex", "available", "logged_out"), runtime("buzz-agent", "available", "not_applicable"), runtime("claude", "available", "logged_in"), + runtime("custom", "available", "not_applicable"), ]; assert.deepEqual( getReadyOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude"], + ["claude", "goose", "buzz-agent"], ); }); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afea..cd491dfcc5a 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,11 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +export const ONBOARDING_RUNTIME_ORDER = [ + "claude", + "codex", + "goose", + "buzz-agent", +]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER, diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index d007bd6118b..1c3e86f1335 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -57,9 +57,7 @@ async function readSavedRuntime(page: Parameters[0]) { }); } -test("setup shows only Claude Code and Codex as detected harnesses", async ({ - page, -}) => { +test("setup shows all bundled harnesses as detected", async ({ page }) => { await installMockBridge( page, { @@ -77,10 +75,8 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-codex")).toBeVisible(); - await expect(page.getByTestId("onboarding-runtime-goose")).toHaveCount(0); - await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toHaveCount( - 0, - ); + await expect(page.getByTestId("onboarding-runtime-goose")).toBeVisible(); + await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toBeVisible(); await expect(page.getByRole("checkbox")).toHaveCount(0); }); @@ -559,8 +555,8 @@ test("defaults auto-selects the only ready visible harness", async ({ page, { acpRuntimesCatalog: [ - runtime("buzz-agent", "available", { status: "not_applicable" }), - runtime("goose", "available", { status: "not_applicable" }), + runtime("buzz-agent", "not_installed", { status: "not_applicable" }), + runtime("goose", "not_installed", { status: "not_applicable" }), runtime("claude", "available", { status: "logged_in" }), runtime("codex", "available", { status: "logged_out" }), ], @@ -660,10 +656,10 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy ).toBeVisible(); await expect( page.getByTestId("global-agent-default-harness-option-goose"), - ).toHaveCount(0); + ).toBeVisible(); await expect( page.getByTestId("global-agent-default-harness-option-buzz-agent"), - ).toHaveCount(0); + ).toBeVisible(); await page.getByTestId("global-agent-default-harness-option-codex").click(); await expect(harness).toHaveText("Codex"); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); @@ -836,3 +832,89 @@ test("concurrent installs each keep their own state — one fails, one succeeds" }); expect(hasHorizontalOverflow).toBe(false); }); + +test("Finish stays disabled until a provider-required harness is fully configured", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + ], + discoverAgentModels: { + models: [{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }], + supportsSwitching: true, + }, + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + // buzz-agent auto-selects as the only ready harness, but with no provider + // configured the default is not launchable — Finish must be gated. + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Buzz", + ); + const finish = page.getByTestId("onboarding-finish"); + await expect(finish).toBeDisabled(); + + // Configure provider + credential; model resolves via discovery/fallback. + await page.getByTestId("global-agent-provider").click(); + await page.getByTestId("global-agent-provider-option-anthropic").click(); + await page.getByTestId("persona-provider-api-key").fill("sk-test-key"); + + await expect(finish).toBeEnabled(); + await finish.click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBe("buzz-agent"); +}); + +test("baked build config keeps Finish enabled without manual provider setup", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + ], + bakedBuildEnv: [ + { key: "BUZZ_AGENT_PROVIDER", masked: false, value: "databricks_v2" }, + { + key: "DATABRICKS_HOST", + masked: false, + value: "https://example.cloud.databricks.com", + }, + { key: "DATABRICKS_MODEL", masked: false, value: "baked-model" }, + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + + // Internal builds bake provider/model/credentials — the gate must treat + // baked config as complete and never block Finish. + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Buzz", + ); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); +}); From 87b3fcd3c0131683569dd4268b099d18b25dcd5e Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Mon, 27 Jul 2026 18:17:39 +0530 Subject: [PATCH 14/71] fix(desktop): clarify identity key button when key exists (#2357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - backing out of the backup step and clicking the CTA again reuses the existing key - the button still said "Create a new identity key", which is misleading for a security-sensitive action - when a key is already loaded, label it "Continue with existing identity key" Closes #2318 ## Test plan - [ ] first-run: button still says "Create a new identity key" - [ ] create key → backup → back: button says "Continue with existing identity key" and shows the same key Made with [Cursor](https://cursor.com) --------- Signed-off-by: Taksh Co-authored-by: Cursor --- .../features/onboarding/ui/MachineOnboardingFlow.tsx | 10 ++++++++-- desktop/tests/e2e/onboarding-backup.spec.ts | 7 ++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index d7fc9071d8d..ca87c76636b 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -171,7 +171,11 @@ export function MachineOnboardingFlow({ onClick={() => void loadFreshIdentity()} type="button" > - {isPending ? "Saving identity…" : "Create a new identity key"} + {isPending + ? "Loading identity…" + : selectedPubkey + ? "Continue setup" + : "Create a new identity key"}
diff --git a/desktop/tests/e2e/onboarding-backup.spec.ts b/desktop/tests/e2e/onboarding-backup.spec.ts index 14f50e924fc..9b49f8bf186 100644 --- a/desktop/tests/e2e/onboarding-backup.spec.ts +++ b/desktop/tests/e2e/onboarding-backup.spec.ts @@ -85,8 +85,13 @@ test("backup step back button returns to machine identity choice", async ({ await expect(page.getByTestId("onboarding-page-backup")).toBeVisible(); await page.getByTestId("onboarding-back").click(); + // Backing out preserves the loaded key — primary CTA continues setup rather + // than minting another identity (#2318). await expect( - page.getByRole("button", { name: "Create a new identity key" }), + page.getByRole("button", { name: "Continue setup" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Use a different key instead" }), ).toBeVisible(); }); From c5c4f390b6713256e2efb8394c59823ebad73db6 Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Mon, 27 Jul 2026 16:14:22 +0200 Subject: [PATCH 15/71] feat(desktop): handle project work from Inbox (#3117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pull requests and issues that mention you now appear as repository-scoped Inbox conversations, so project work can be reviewed without first navigating to Projects. Opening a project item resolves its current canonical state and reuses the existing review, comment, merge, and issue actions. Repository-aware grouping keeps identical event IDs from different repositories separate, while loading, missing-data, and partial-query states avoid exposing stale actions. ### Related issue None found. ### Testing - `node --import ./test-loader.mjs --experimental-strip-types --test src/features/home/lib/projectInbox.test.mjs` — 6 tests passed - `CI=1 pnpm exec playwright test tests/e2e/project-inbox.spec.ts --project=smoke` — passed - Pre-push desktop, mobile, Tauri, and Rust checks — passed --------- Signed-off-by: Thomas Petersen --- crates/buzz-db/src/feed.rs | 19 +- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/commands/messages.rs | 15 +- desktop/src/features/home/lib/inbox.ts | 87 ++++++- .../src/features/home/lib/inboxViewHelpers.ts | 7 + .../features/home/lib/projectInbox.test.mjs | 235 ++++++++++++++++++ desktop/src/features/home/lib/projectInbox.ts | 99 ++++++++ desktop/src/features/home/ui/HomeView.tsx | 4 +- .../src/features/home/ui/InboxDetailPane.tsx | 20 +- .../src/features/home/ui/InboxListPane.tsx | 1 + .../features/home/ui/ProjectInboxDetail.tsx | 129 ++++++++++ .../home/ui/ProjectInboxDetailPane.tsx | 178 +++++++++++++ .../features/projects/ui/ProjectFeedRow.tsx | 3 + .../projects/ui/ProjectIssuesPanel.tsx | 30 ++- .../projects/ui/ProjectPullRequestsPanel.tsx | 16 +- desktop/src/testing/e2eBridge.ts | 19 +- desktop/tests/e2e/project-inbox.spec.ts | 117 +++++++++ 17 files changed, 952 insertions(+), 28 deletions(-) create mode 100644 desktop/src/features/home/lib/projectInbox.test.mjs create mode 100644 desktop/src/features/home/lib/projectInbox.ts create mode 100644 desktop/src/features/home/ui/ProjectInboxDetail.tsx create mode 100644 desktop/src/features/home/ui/ProjectInboxDetailPane.tsx create mode 100644 desktop/tests/e2e/project-inbox.spec.ts diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 7cc02d8011a..511a2a60835 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -34,9 +34,10 @@ use sqlx::{PgPool, QueryBuilder}; use uuid::Uuid; use buzz_core::kind::{ - KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, - KIND_WORKFLOW_APPROVAL_REQUESTED, + KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, + KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_WORKFLOW_APPROVAL_REQUESTED, }; use buzz_core::{CommunityId, StoredEvent}; @@ -103,7 +104,9 @@ fn build_mentions_query( qb.push(" AND e.deleted_at IS NULL"); qb.push(format!( " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \ - {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + {KIND_TEXT_NOTE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT}, {KIND_GIT_PULL_REQUEST}, \ + {KIND_GIT_PR_UPDATE}, {KIND_GIT_ISSUE}, {KIND_GIT_STATUS_OPEN}, \ + {KIND_GIT_STATUS_MERGED}, {KIND_GIT_STATUS_CLOSED}, {KIND_GIT_STATUS_DRAFT})" )); push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { @@ -252,7 +255,7 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -777,6 +780,12 @@ mod tests { sql.contains("AND m.community_id = "), "mentions feed must also bind event_mentions.community_id: {sql}" ); + assert!( + sql.contains(&KIND_GIT_PULL_REQUEST.to_string()) + && sql.contains(&KIND_GIT_ISSUE.to_string()) + && sql.contains(&KIND_TEXT_NOTE.to_string()), + "mentions feed must include Buzz Git roots and comments: {sql}" + ); } #[test] diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ba35481bd00..7e316dd5432 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -98,6 +98,7 @@ export default defineConfig({ "**/inbox-reactions.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", + "**/project-inbox.spec.ts", "**/project-pr-review.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 1928d69d23d..afe94cdfe62 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -68,7 +68,20 @@ pub async fn get_feed( // Mentions: messages that reference me via #p. let mut mention_filter = serde_json::json!({ - "kinds": [9, 40002, 1, 45001, 45003], + "kinds": [ + 9, + 40002, + 1, + 45001, + 45003, + buzz_core_pkg::kind::KIND_GIT_PULL_REQUEST, + buzz_core_pkg::kind::KIND_GIT_PR_UPDATE, + buzz_core_pkg::kind::KIND_GIT_ISSUE, + buzz_core_pkg::kind::KIND_GIT_STATUS_OPEN, + buzz_core_pkg::kind::KIND_GIT_STATUS_MERGED, + buzz_core_pkg::kind::KIND_GIT_STATUS_CLOSED, + buzz_core_pkg::kind::KIND_GIT_STATUS_DRAFT, + ], "#p": [my_pubkey], "limit": cap, }); diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index b4c05ef8095..a34ea1b66f9 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -6,6 +6,10 @@ import { getThreadReference, isBroadcastReply, } from "@/features/messages/lib/threading"; +import { + getProjectInboxReference, + isProjectInboxItem, +} from "@/features/home/lib/projectInbox"; import type { TimelineReaction } from "@/features/messages/types"; import type { Channel, @@ -18,6 +22,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; export type InboxFilter = | "all" + | "project" | "mention" | "thread" | "needs_action" @@ -29,10 +34,10 @@ export type InboxFilter = export type InboxItem = { avatarUrl: string | null; /** - * Stable conversation identity: `rootId ?? parentId ?? event.id` for the - * thread group. Does NOT change when a new reply advances the representative - * latest event. Use this for lifecycle continuity: scroll gating, draft - * keys, local-reply storage, and selection identity. + * Stable conversation identity: the NIP-10 root for messages, or a + * repository-scoped root for Buzz Git work. Does NOT change when a new reply + * advances the representative latest event. Use this for lifecycle + * continuity: scroll gating, draft keys, local-reply storage, and selection. */ conversationId: string; id: string; @@ -136,7 +141,33 @@ function diffInDays(from: Date, to: Date) { ); } -function feedHeadline(item: FeedItem) { +function tagValue(item: FeedItem, name: string) { + return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null; +} + +function projectRootItem(item: FeedItem, groupItems: readonly FeedItem[]) { + return ( + groupItems.find( + (candidate) => candidate.kind === 1618 || candidate.kind === 1621, + ) ?? item + ); +} + +function projectTypeLabel(item: FeedItem) { + if (item.kind === 1618) return "Pull request"; + if (item.kind === 1621) return "Issue"; + return "Project update"; +} + +function feedHeadline(item: FeedItem, groupItems: readonly FeedItem[] = []) { + if (isProjectInboxItem(item)) { + const root = projectRootItem(item, groupItems); + return ( + (tagValue(root, "subject") ?? root.content.trim().split("\n")[0]) || + projectTypeLabel(root) + ); + } + switch (item.kind) { case 40007: return "Reminder"; @@ -242,6 +273,14 @@ function resolveGroupChannel( export function getInboxTypeLabel(item: InboxItem): InboxTypeLabel { const channelName = item.channelLabel; + if (item.groupItems.some(isProjectInboxItem)) { + const root = projectRootItem(item.item, item.groupItems); + return { + text: projectTypeLabel(root), + channelLabel: null, + }; + } + if (item.item.channelType === "dm") { return { text: item.senderLabel ? `DM from ${item.senderLabel}` : "DM", @@ -300,23 +339,50 @@ function categoryPriority(category: FeedItemCategory) { } function getInboxThreadKey(item: FeedItem) { + const projectReference = getProjectInboxReference(item); + if (projectReference) { + return `project:${projectReference.repoAddress}:${projectReference.rootId}`; + } + const thread = getThreadReference(item.tags); return thread.rootId ?? thread.parentId ?? item.id; } +function getStableConversationId(item: FeedItem) { + return getInboxItemConversationId(item); +} + /** - * Returns the stable conversation ID for any FeedItem or relay event: the - * NIP-10 root tag id, falling back to parent-reply tag id, then event id. + * Returns the stable conversation ID for any FeedItem or relay event. Buzz Git + * roots include their repository coordinate; messages use the NIP-10 root, + * parent-reply tag, then event id. * This is the same derivation used by `buildInboxItems` for `conversationId`. */ export function getInboxConversationId( tags: string[][], eventId: string, + kind?: number, ): string { + if (kind !== undefined) { + const projectReference = getProjectInboxReference({ + id: eventId, + kind, + tags, + }); + if (projectReference) { + return `project:${projectReference.repoAddress}:${projectReference.rootId}`; + } + } + const thread = getThreadReference(tags); return thread.rootId ?? thread.parentId ?? eventId; } +/** Returns the stable conversation identity for a complete Inbox feed item. */ +export function getInboxItemConversationId(item: FeedItem) { + return getInboxConversationId(item.tags, item.id, item.kind); +} + function formatInboxTimestamp(unixSeconds: number) { const date = new Date(unixSeconds * 1_000); const now = new Date(); @@ -436,7 +502,7 @@ export function buildInboxItems({ group.items.push(item); group.latestActivityAt = Math.max(group.latestActivityAt, item.createdAt); - if (item.id === threadKey) { + if (item.id === getStableConversationId(item)) { group.rootItem = item; } @@ -447,7 +513,8 @@ export function buildInboxItems({ .sort( ([, left], [, right]) => right.latestActivityAt - left.latestActivityAt, ) - .map(([conversationId, group]) => { + .map(([, group]) => { + const conversationId = getStableConversationId(group.items[0]); const latestItem = group.items.reduce((latest, current) => current.createdAt > latest.createdAt ? current : latest, ); @@ -461,7 +528,7 @@ export function buildInboxItems({ profiles, preferResolvedSelfLabel: true, }); - const subject = feedHeadline(item); + const subject = feedHeadline(item, group.items); const preview = feedPreview(item); const { mentionNames, mentionPubkeysByName } = resolveMentionProps( item.tags, diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index e87671b9fa9..869dbf18181 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -3,6 +3,7 @@ import { type InboxContextMessage, type InboxFilter, } from "@/features/home/lib/inbox"; +import { isProjectInboxItem } from "@/features/home/lib/projectInbox"; import { getChannelIdFromTags, getThreadReference, @@ -39,6 +40,12 @@ export function matchesInboxFilter( ); } + if (filter === "project") { + return [item.item, ...(item.groupItems ?? [])].some( + (groupItem) => groupItem && isProjectInboxItem(groupItem), + ); + } + return item.categories.includes(filter); } diff --git a/desktop/src/features/home/lib/projectInbox.test.mjs b/desktop/src/features/home/lib/projectInbox.test.mjs new file mode 100644 index 00000000000..040f54e8470 --- /dev/null +++ b/desktop/src/features/home/lib/projectInbox.test.mjs @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildInboxItems, getInboxTypeLabel } from "./inbox.ts"; +import { matchesInboxFilter } from "./inboxViewHelpers.ts"; +import { + getProjectInboxReference, + isProjectInboxItem, + resolveProjectInboxWorkItem, +} from "./projectInbox.ts"; + +const OWNER = "a".repeat(64); +const REVIEWER = "b".repeat(64); +const REPO_ADDRESS = `30617:${OWNER}:buzz`; +const PR_ID = "c".repeat(64); +const ISSUE_ID = "d".repeat(64); + +function feedItem(overrides = {}) { + return { + id: PR_ID, + kind: 1618, + pubkey: OWNER, + content: "Inbox support", + createdAt: 1_700_000_000, + channelId: null, + channelName: "", + tags: [ + ["a", REPO_ADDRESS], + ["p", REVIEWER], + ["subject", "Add project work items to Inbox"], + ], + category: "mention", + ...overrides, + }; +} + +const project = { + id: "buzz", + name: "Buzz", + owner: OWNER, + repoAddress: REPO_ADDRESS, +}; + +const pullRequest = { + id: PR_ID, + author: OWNER, + title: "Add project work items to Inbox", +}; + +const issue = { + id: ISSUE_ID, + author: REVIEWER, + title: "Inbox issue", +}; + +test("recognizes project roots and project thread activity", () => { + assert.equal(isProjectInboxItem(feedItem()), true); + assert.equal( + isProjectInboxItem( + feedItem({ + id: "e".repeat(64), + kind: 1, + tags: [ + ["a", REPO_ADDRESS], + ["e", PR_ID, "", "root"], + ["p", REVIEWER], + ], + }), + ), + true, + ); + assert.equal( + isProjectInboxItem( + feedItem({ + kind: 9, + tags: [ + ["a", REPO_ADDRESS], + ["e", PR_ID, "", "root"], + ], + }), + ), + false, + ); +}); + +test("resolves the canonical project root from status and comment events", () => { + assert.deepEqual(getProjectInboxReference(feedItem()), { + repoAddress: REPO_ADDRESS, + rootId: PR_ID, + }); + assert.deepEqual( + getProjectInboxReference( + feedItem({ + id: "f".repeat(64), + kind: 1631, + tags: [ + ["a", REPO_ADDRESS], + ["e", PR_ID, "", "root"], + ], + }), + ), + { + repoAddress: REPO_ADDRESS, + rootId: PR_ID, + }, + ); +}); + +test("matches a selected inbox event to its canonical pull request or issue", () => { + const workItems = { + pullRequests: { + items: [{ project, pullRequest }], + failedSections: [], + }, + issues: { + items: [{ project, issue }], + failedSections: [], + }, + }; + + assert.deepEqual(resolveProjectInboxWorkItem(feedItem(), workItems), { + type: "pull-request", + project, + pullRequest, + }); + assert.deepEqual( + resolveProjectInboxWorkItem( + feedItem({ + id: ISSUE_ID, + kind: 1621, + tags: [ + ["a", REPO_ADDRESS], + ["p", OWNER], + ["subject", "Inbox issue"], + ], + }), + workItems, + ), + { + type: "issue", + project, + issue, + }, + ); +}); + +test("presents project work with its canonical subject and project filter", () => { + const [item] = buildInboxItems({ + feed: { + feed: { + mentions: [feedItem()], + needsAction: [], + activity: [], + agentActivity: [], + }, + meta: { since: 0, total: 1, generatedAt: 1_700_000_000 }, + }, + }); + + assert.equal(item.subject, "Add project work items to Inbox"); + assert.deepEqual(getInboxTypeLabel(item), { + text: "Pull request", + channelLabel: null, + }); + assert.equal(matchesInboxFilter(item, "project"), true); + assert.equal( + matchesInboxFilter( + { + ...item, + item: feedItem({ kind: 9, tags: [["h", "channel-id"]] }), + groupItems: [feedItem({ kind: 9, tags: [["h", "channel-id"]] })], + }, + "project", + ), + false, + ); +}); + +test("groups uppercase NIP-34 pull request updates with their root", () => { + const update = feedItem({ + id: "f".repeat(64), + kind: 1619, + createdAt: 1_700_000_100, + content: "Pushed another commit", + tags: [ + ["a", REPO_ADDRESS], + ["E", PR_ID], + ["p", REVIEWER], + ], + }); + const items = buildInboxItems({ + feed: { + feed: { + mentions: [feedItem(), update], + needsAction: [], + activity: [], + agentActivity: [], + }, + meta: { since: 0, total: 2, generatedAt: 1_700_000_100 }, + }, + }); + + assert.equal(items.length, 1); + assert.equal(items[0].id, update.id); + assert.equal(items[0].subject, "Add project work items to Inbox"); +}); + +test("does not group project events from different repositories", () => { + const otherRepoAddress = `30617:${"e".repeat(64)}:other`; + const items = buildInboxItems({ + feed: { + feed: { + mentions: [ + feedItem(), + feedItem({ + id: "f".repeat(64), + kind: 1619, + tags: [ + ["a", otherRepoAddress], + ["E", PR_ID], + ["p", REVIEWER], + ], + }), + ], + needsAction: [], + activity: [], + agentActivity: [], + }, + meta: { since: 0, total: 2, generatedAt: 1_700_000_100 }, + }, + }); + + assert.equal(items.length, 2); + assert.notEqual(items[0].conversationId, items[1].conversationId); +}); diff --git a/desktop/src/features/home/lib/projectInbox.ts b/desktop/src/features/home/lib/projectInbox.ts new file mode 100644 index 00000000000..a22b355214f --- /dev/null +++ b/desktop/src/features/home/lib/projectInbox.ts @@ -0,0 +1,99 @@ +import type { + Project, + ProjectIssue, + ProjectPullRequest, +} from "@/features/projects/hooks"; +import type { ProjectsWorkItemsResult } from "@/features/projects/projectWorkItems"; +import type { FeedItem } from "@/shared/api/types"; +import { + KIND_GIT_ISSUE, + KIND_GIT_PR_UPDATE, + KIND_GIT_PULL_REQUEST, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_OPEN, + KIND_TEXT_NOTE, +} from "@/shared/constants/kinds"; + +const PROJECT_ROOT_KINDS = new Set([KIND_GIT_PULL_REQUEST, KIND_GIT_ISSUE]); +const PROJECT_ACTIVITY_KINDS = new Set([ + KIND_TEXT_NOTE, + KIND_GIT_PR_UPDATE, + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, +]); +const REPO_ADDRESS_PATTERN = /^30617:[0-9a-f]{64}:.+$/i; + +export type ProjectInboxWorkItem = + | { + type: "pull-request"; + project: Project; + pullRequest: ProjectPullRequest; + } + | { + type: "issue"; + project: Project; + issue: ProjectIssue; + }; + +function tagValue(item: Pick, name: string) { + return item.tags.find( + (tag) => tag[0] === name && typeof tag[1] === "string" && tag[1].length > 0, + )?.[1]; +} + +/** Returns the canonical Buzz Git repository and root event for an Inbox row. */ +export function getProjectInboxReference( + item: Pick, +): { repoAddress: string; rootId: string } | null { + const repoAddress = tagValue(item, "a"); + if (!repoAddress || !REPO_ADDRESS_PATTERN.test(repoAddress)) { + return null; + } + + if (PROJECT_ROOT_KINDS.has(item.kind)) { + return { repoAddress, rootId: item.id }; + } + + if (!PROJECT_ACTIVITY_KINDS.has(item.kind)) { + return null; + } + + const rootId = tagValue(item, "e") ?? tagValue(item, "E"); + return rootId ? { repoAddress, rootId } : null; +} + +/** Whether a feed event belongs to a Buzz Git pull request or issue thread. */ +export function isProjectInboxItem(item: FeedItem) { + return getProjectInboxReference(item) !== null; +} + +/** Resolves an Inbox event to the current canonical Buzz Git work item. */ +export function resolveProjectInboxWorkItem( + item: FeedItem, + workItems: ProjectsWorkItemsResult | undefined, +): ProjectInboxWorkItem | null { + const reference = getProjectInboxReference(item); + if (!reference || !workItems) { + return null; + } + + const pullRequestEntry = workItems.pullRequests.items.find( + ({ project, pullRequest }) => + project.repoAddress === reference.repoAddress && + pullRequest.id === reference.rootId, + ); + if (pullRequestEntry) { + return { type: "pull-request", ...pullRequestEntry }; + } + + const issueEntry = workItems.issues.items.find( + ({ issue, project }) => + project.repoAddress === reference.repoAddress && + issue.id === reference.rootId, + ); + return issueEntry ? { type: "issue", ...issueEntry } : null; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 992702f4311..2bcb231e9d9 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -14,7 +14,7 @@ import { type InboxReply, buildInboxItems, formatInboxFullTimestamp, - getInboxConversationId, + getInboxItemConversationId, } from "@/features/home/lib/inbox"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; import { @@ -413,7 +413,7 @@ export function HomeView({ // correct row selected (by conversationId) even after the anchor event has // been displaced from groupItems by a newer representative. const latchedConversationId = activeLatchedItem - ? getInboxConversationId(activeLatchedItem.tags, activeLatchedItem.id) + ? getInboxItemConversationId(activeLatchedItem) : null; const selectedConversationId = selectedItemFromAll?.conversationId ?? latchedConversationId; diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 23a1748225a..5d6d98323f1 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -6,6 +6,8 @@ import type { InboxItem, InboxReply, } from "@/features/home/lib/inbox"; +import { getProjectInboxReference } from "@/features/home/lib/projectInbox"; +import { ProjectInboxDetail } from "@/features/home/ui/ProjectInboxDetail"; import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar"; import { useCommunities } from "@/features/communities/useCommunities"; import { formatInboxTypeLabel } from "@/features/home/lib/inbox"; @@ -102,7 +104,23 @@ type InboxDetailPaneProps = { ) => Promise; }; -export function InboxDetailPane({ +/** Routes Inbox selections to their canonical message or Buzz Git detail. */ +export function InboxDetailPane(props: InboxDetailPaneProps) { + if (props.item && getProjectInboxReference(props.item.item)) { + return ( + + ); + } + + return ; +} + +function InboxMessageDetailPane({ agentPubkeys, canDelete, canOpenChannel, diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index b92008e7a25..603ebc2beb6 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -50,6 +50,7 @@ import { VirtualizedList } from "@/shared/ui/VirtualizedList"; const FILTER_OPTIONS: Array<{ label: string; value: InboxFilter }> = [ { value: "all", label: "All" }, + { value: "project", label: "Projects" }, { value: "mention", label: "Mentions" }, { value: "thread", label: "Threads" }, { value: "needs_action", label: "Needs Action" }, diff --git a/desktop/src/features/home/ui/ProjectInboxDetail.tsx b/desktop/src/features/home/ui/ProjectInboxDetail.tsx new file mode 100644 index 00000000000..c3d17e687ea --- /dev/null +++ b/desktop/src/features/home/ui/ProjectInboxDetail.tsx @@ -0,0 +1,129 @@ +import { ArrowLeft } from "lucide-react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import type { InboxItem } from "@/features/home/lib/inbox"; +import { resolveProjectInboxWorkItem } from "@/features/home/lib/projectInbox"; +import { ProjectInboxDetailPane } from "@/features/home/ui/ProjectInboxDetailPane"; +import { + useProjectsQuery, + useProjectsWorkItemsQuery, +} from "@/features/projects/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { Button } from "@/shared/ui/button"; + +type ProjectInboxDetailProps = { + isSinglePanelView?: boolean; + item: InboxItem; + onBack?: () => void; + profiles?: UserProfileLookup; +}; + +function ProjectInboxStatus({ + message, + onBack, + onRetry, +}: { + message: string; + onBack?: () => void; + onRetry?: () => void; +}) { + return ( +
+ {onBack ? ( +
+ +
+ ) : null} +
+

{message}

+ {onRetry ? ( + + ) : null} +
+
+ ); +} + +/** Resolves and renders the live Buzz Git object selected from Inbox. */ +export function ProjectInboxDetail({ + isSinglePanelView = false, + item, + onBack, + profiles, +}: ProjectInboxDetailProps) { + const { goProject } = useAppNavigation(); + const projectsQuery = useProjectsQuery(); + const projectsWorkItemsQuery = useProjectsWorkItemsQuery( + projectsQuery.data ?? [], + ); + const workItem = resolveProjectInboxWorkItem( + item.item, + projectsWorkItemsQuery.data, + ); + + if (!workItem) { + const error = projectsQuery.error ?? projectsWorkItemsQuery.error; + const isLoading = + projectsQuery.isLoading || projectsWorkItemsQuery.isLoading; + return ( + { + void projectsQuery.refetch(); + void projectsWorkItemsQuery.refetch(); + } + : undefined + } + /> + ); + } + + const failedSections = + workItem.type === "pull-request" + ? projectsWorkItemsQuery.data?.pullRequests.failedSections + : projectsWorkItemsQuery.data?.issues.failedSections; + if (failedSections && failedSections.length > 0) { + return ( + void projectsWorkItemsQuery.refetch()} + /> + ); + } + + return ( + { + const workItemId = + workItem.type === "pull-request" + ? { pullRequestId: workItem.pullRequest.id } + : { issueId: workItem.issue.id }; + void goProject(workItem.project.id, workItemId); + }} + profiles={profiles} + workItem={workItem} + /> + ); +} diff --git a/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx new file mode 100644 index 00000000000..5b80c13cb98 --- /dev/null +++ b/desktop/src/features/home/ui/ProjectInboxDetailPane.tsx @@ -0,0 +1,178 @@ +import { ArrowLeft, ExternalLink } from "lucide-react"; +import * as React from "react"; + +import { useCommunities } from "@/features/communities/useCommunities"; +import type { ProjectInboxWorkItem } from "@/features/home/lib/projectInbox"; +import { ProjectIssueDetail } from "@/features/projects/ui/ProjectIssuesPanel"; +import { + ProjectPullRequestDetail, + PullRequestDetailHeader, + PullRequestMetaRail, +} from "@/features/projects/ui/ProjectPullRequestsPanel"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit"; +import { useElementWidth } from "@/shared/hooks/use-mobile"; +import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +type ProjectInboxDetailPaneProps = { + isSinglePanelView?: boolean; + onBack?: () => void; + onOpenProject: () => void; + profiles?: UserProfileLookup; + workItem: ProjectInboxWorkItem; +}; + +/** Renders a canonical Buzz Git work item with its existing project actions. */ +export function ProjectInboxDetailPane({ + isSinglePanelView = false, + onBack, + onOpenProject, + profiles, + workItem, +}: ProjectInboxDetailPaneProps) { + const { activeCommunity } = useCommunities(); + const [detailContentRef, detailContentWidth] = + useElementWidth(); + const showSideRail = detailContentWidth >= 760; + const authorPubkey = + workItem.type === "pull-request" + ? workItem.pullRequest.author + : workItem.issue.author; + const authorLabel = resolveUserLabel({ profiles, pubkey: authorPubkey }); + const authorAvatarUrl = + profiles?.[normalizePubkey(authorPubkey)]?.avatarUrl ?? null; + const inboxTitle = `${authorLabel} sent you ${ + workItem.type === "pull-request" ? "a pull request" : "an issue" + }`; + const handleOpenMergeRecoveryTerminal = React.useCallback( + async (input: { + expectedCommit: string; + sourceBranch: string; + sourceCloneUrl: string; + targetBranch: string; + }) => { + if (workItem.type !== "pull-request") { + throw new Error("Merge recovery is only available for pull requests."); + } + const targetCloneUrl = workItem.project.cloneUrls[0]; + if (!targetCloneUrl) { + throw new Error("This project has no clone URL."); + } + return openProjectMergeRecoveryTerminal({ + ...input, + projectDtag: workItem.project.dtag, + reposDir: activeCommunity?.reposDir, + targetCloneUrl, + }); + }, + [activeCommunity?.reposDir, workItem], + ); + + return ( +
+ +
+
+
+ {isSinglePanelView && onBack ? ( + + ) : null} + +

+ {inboxTitle} +

+
+ +
+
+
+ +
+
+
+ {workItem.type === "pull-request" ? ( +
+
+ + +
+ +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectFeedRow.tsx b/desktop/src/features/projects/ui/ProjectFeedRow.tsx index ae5a47798ff..4747d03519b 100644 --- a/desktop/src/features/projects/ui/ProjectFeedRow.tsx +++ b/desktop/src/features/projects/ui/ProjectFeedRow.tsx @@ -7,6 +7,7 @@ import type * as React from "react"; * trailing cluster (hash, id, comment count) on the right. */ export function ProjectFeedRow({ + eventId, meta, onOpen, statusIcon, @@ -14,6 +15,7 @@ export function ProjectFeedRow({ title, trailing, }: { + eventId?: string; meta: React.ReactNode; onOpen?: () => void; statusIcon?: React.ReactNode; @@ -24,6 +26,7 @@ export function ProjectFeedRow({ return (
diff --git a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx index b3215aa11b0..104000e553c 100644 --- a/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectIssuesPanel.tsx @@ -15,6 +15,7 @@ import { } from "@/features/profile/lib/identity"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ChannelMember } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { ProjectFeedRow, @@ -158,14 +159,17 @@ function IssueRow({ ); } -function IssueDetail({ +/** Full issue conversation and comment composer. */ +export function ProjectIssueDetail({ issue, profiles, project, + stackMetaRail = false, }: { issue: ProjectIssue; profiles?: UserProfileLookup; project: Project; + stackMetaRail?: boolean; }) { const commentMutation = useCreateProjectIssueCommentMutation(project); const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author }); @@ -198,7 +202,12 @@ function IssueDetail({ ); return ( -
+
@@ -253,7 +262,11 @@ function IssueDetail({
- + ); } @@ -263,16 +276,23 @@ function IssueDetail({ function IssueMetaRail({ issue, profiles, + stacked = false, }: { issue: ProjectIssue; profiles?: UserProfileLookup; + stacked?: boolean; }) { const authorProfile = profiles?.[normalizePubkey(issue.author)]; const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author }); const status = issueStatusVisual(issue.status); return ( -