feat(app): improve workspace service controls

Keep service route choices independent of daemon transport and persist the selected route per host. Use the shared web overlay stack so menus, tooltips, and toasts layer predictably.

Track proxy route changes from the Git branch rather than the workspace title to avoid transient service health changes.
This commit is contained in:
Mohamed Boudra
2026-07-21 13:44:23 +02:00
parent afeb4d18f8
commit 8aa55db1e8
20 changed files with 1242 additions and 428 deletions
@@ -11,6 +11,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
@@ -33,6 +34,7 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isWeb } from "@/constants/platform";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
@@ -496,6 +498,17 @@ export function DropdownMenuContent({
setOpen(false);
}, [setOpen]);
useEffect(() => {
if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.stopPropagation();
handleClose();
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleClose, modalVisible]);
// Measure trigger when opening
useEffect(() => {
if (!open || !triggerRef.current) {
@@ -613,6 +626,34 @@ export function DropdownMenuContent({
</View>
);
const overlay = (
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
</View>
);
if (isWeb && typeof document !== "undefined") {
return createPortal(overlay, getOverlayRoot());
}
return (
<Modal
visible={modalVisible}
@@ -622,27 +663,7 @@ export function DropdownMenuContent({
onDismiss={flushPendingSelect}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
{!closing
? renderDropdownSurface({
frameStyle,
testID,
surfaceStyle,
scrollable,
scrollViewportStyle,
content,
surfaceNativeID,
onExited: () => setModalVisible(false),
})
: null}
</View>
{overlay}
</Modal>
);
}
@@ -878,6 +899,11 @@ const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
backdrop: {
position: "absolute",
top: 0,
+19 -21
View File
@@ -12,6 +12,7 @@ import {
type PropsWithChildren,
type ReactElement,
} from "react";
import { createPortal } from "react-dom";
import {
Dimensions,
Platform,
@@ -23,13 +24,12 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
type Side = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
@@ -446,7 +446,6 @@ export function TooltipContent({
maxWidth?: number;
}>): ReactElement | null {
const ctx = useTooltipContext("TooltipContent");
const bottomSheetInternal = useBottomSheetModalInternal(true);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
@@ -516,23 +515,22 @@ export function TooltipContent({
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
// exact same positioning math as DropdownMenu, without hover feedback loops.
if (isWeb) {
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>
</Portal>
return createPortal(
<View pointerEvents="none" style={styles.portalOverlay}>
<FloatingSurface
pointerEvents="none"
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={contentStyle}
frameStyle={frameStyle}
>
{children}
</FloatingSurface>
</View>,
getOverlayRoot(),
);
}
@@ -570,7 +568,7 @@ const styles = StyleSheet.create((theme) => ({
right: 0,
bottom: 0,
left: 0,
zIndex: 1000,
zIndex: OVERLAY_Z.tooltip,
},
content: {
paddingVertical: theme.spacing[1],