Merge origin/main into daemon-server-id-host-selection
This commit is contained in:
@@ -7,11 +7,10 @@ import {
|
||||
} from "react-native";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ArrowUp, AudioLines, MicOff, Square, Pencil } from "lucide-react-native";
|
||||
import { ArrowUp, Square, Pencil } from "lucide-react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useVoice } from "@/contexts/voice-context";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
@@ -94,8 +93,6 @@ export function AgentInputArea({
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
|
||||
const { isVoiceMode, isMuted: isVoiceMuted, toggleMute: toggleVoiceMute } = useVoice();
|
||||
|
||||
const [internalInput, setInternalInput] = useState("");
|
||||
const userInput = value ?? internalInput;
|
||||
const setUserInput = onChangeText ?? setInternalInput;
|
||||
@@ -573,35 +570,6 @@ export function AgentInputArea({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Voice quick mute indicator */}
|
||||
{isVoiceMode && (
|
||||
<View style={styles.voiceIndicatorRow}>
|
||||
<Pressable
|
||||
onPress={toggleVoiceMute}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isVoiceMuted ? "Unmute voice" : "Mute voice"}
|
||||
style={[
|
||||
styles.voiceIndicatorPill,
|
||||
isVoiceMuted && styles.voiceIndicatorPillMuted,
|
||||
]}
|
||||
>
|
||||
{isVoiceMuted ? (
|
||||
<MicOff size={14} color={theme.colors.surface0} />
|
||||
) : (
|
||||
<AudioLines size={14} color={theme.colors.foreground} />
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
styles.voiceIndicatorText,
|
||||
isVoiceMuted && styles.voiceIndicatorTextMuted,
|
||||
]}
|
||||
>
|
||||
{isVoiceMuted ? "Voice muted" : "Voice on"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
@@ -666,33 +634,6 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
voiceIndicatorRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
voiceIndicatorPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
voiceIndicatorPillMuted: {
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
borderColor: theme.colors.palette.red[800],
|
||||
},
|
||||
voiceIndicatorText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
voiceIndicatorTextMuted: {
|
||||
color: theme.colors.surface0,
|
||||
},
|
||||
queueContainer: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[2],
|
||||
|
||||
@@ -52,6 +52,8 @@ import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
import { VoiceCompactIndicator } from "./voice-compact-indicator";
|
||||
import { useVoice } from "@/contexts/voice-context";
|
||||
|
||||
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
|
||||
const isToolSequenceItem = (item?: StreamItem) =>
|
||||
@@ -59,6 +61,23 @@ const isToolSequenceItem = (item?: StreamItem) =>
|
||||
const AGENT_STREAM_LOG_TAG = "[AgentStreamView]";
|
||||
const STREAM_ITEM_LOG_MIN_COUNT = 200;
|
||||
const STREAM_ITEM_LOG_DELTA_THRESHOLD = 50;
|
||||
|
||||
function collectAssistantTurnContent(
|
||||
flatListData: StreamItem[],
|
||||
startIndex: number
|
||||
): string {
|
||||
const messages: string[] = [];
|
||||
for (let i = startIndex; i < flatListData.length; i++) {
|
||||
const currentItem = flatListData[i];
|
||||
if (currentItem.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
}
|
||||
return messages.reverse().join("\n\n");
|
||||
}
|
||||
export interface AgentStreamViewProps {
|
||||
agentId: string;
|
||||
serverId?: string;
|
||||
@@ -77,6 +96,7 @@ export function AgentStreamView({
|
||||
const flatListRef = useRef<FlatList<StreamItem>>(null);
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isVoiceMode } = useVoice();
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const hasScrolledInitially = useRef(false);
|
||||
const hasAutoScrolledOnce = useRef(false);
|
||||
@@ -362,26 +382,6 @@ export function AgentStreamView({
|
||||
[handleInlinePathPress, agent.cwd, flatListData]
|
||||
);
|
||||
|
||||
const collectTurnContent = useCallback(
|
||||
(index: number) => {
|
||||
const messages: string[] = [];
|
||||
// Walk backwards (older items) from current index
|
||||
// In inverted list: index+1 is the item above (older in time)
|
||||
for (let i = index; i < flatListData.length; i++) {
|
||||
const currentItem = flatListData[i];
|
||||
if (currentItem.kind === "user_message") {
|
||||
break;
|
||||
}
|
||||
if (currentItem.kind === "assistant_message") {
|
||||
messages.push(currentItem.text);
|
||||
}
|
||||
}
|
||||
// Messages are collected newest-first, reverse for chronological order
|
||||
return messages.reverse().join("\n\n");
|
||||
},
|
||||
[flatListData]
|
||||
);
|
||||
|
||||
const renderStreamItem = useCallback(
|
||||
({ item, index }: ListRenderItemInfo<StreamItem>) => {
|
||||
const content = renderStreamItemContent(item, index);
|
||||
@@ -391,30 +391,13 @@ export function AgentStreamView({
|
||||
|
||||
const gapBelow = getGapBelow(item, index);
|
||||
|
||||
// Check if this is the end of a turn (before a user message or end of stream when not running)
|
||||
// In inverted list: index-1 is the next item (newer in time)
|
||||
const nextItem = flatListData[index - 1];
|
||||
const isEndOfTurn =
|
||||
item.kind !== "user_message" &&
|
||||
(nextItem?.kind === "user_message" ||
|
||||
(nextItem === undefined && agent.status !== "running"));
|
||||
|
||||
const getContent = () => collectTurnContent(index);
|
||||
|
||||
return (
|
||||
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
|
||||
{content}
|
||||
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[
|
||||
getGapBelow,
|
||||
renderStreamItemContent,
|
||||
flatListData,
|
||||
agent.status,
|
||||
collectTurnContent,
|
||||
]
|
||||
[getGapBelow, renderStreamItemContent]
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -494,15 +477,33 @@ export function AgentStreamView({
|
||||
}, [agentId, pendingPermissionItems.length, streamHead, streamItems]);
|
||||
|
||||
const showWorkingIndicator = agent.status === "running";
|
||||
const newestItem = flatListData[0] ?? null;
|
||||
const latestTurnContent = useMemo(() => {
|
||||
if (flatListData.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return collectAssistantTurnContent(flatListData, 0);
|
||||
}, [flatListData]);
|
||||
const showCopyButton =
|
||||
agent.status !== "running" &&
|
||||
newestItem?.kind === "assistant_message" &&
|
||||
latestTurnContent.trim().length > 0;
|
||||
const showBottomBar = showWorkingIndicator || showCopyButton || isVoiceMode;
|
||||
|
||||
const listHeaderComponent = useMemo(() => {
|
||||
const hasPermissions = pendingPermissionItems.length > 0;
|
||||
const hasHeadItems = streamHead && streamHead.length > 0;
|
||||
|
||||
if (!hasPermissions && !showWorkingIndicator && !hasHeadItems) {
|
||||
if (!hasPermissions && !showBottomBar && !hasHeadItems) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leftContent = showWorkingIndicator ? (
|
||||
<WorkingIndicator />
|
||||
) : showCopyButton ? (
|
||||
<TurnCopyButton getContent={() => latestTurnContent} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View style={stylesheet.contentWrapper}>
|
||||
<View
|
||||
@@ -538,9 +539,12 @@ export function AgentStreamView({
|
||||
})
|
||||
: null}
|
||||
|
||||
{showWorkingIndicator ? (
|
||||
<View style={stylesheet.workingIndicatorWrapper}>
|
||||
<WorkingIndicator />
|
||||
{showBottomBar ? (
|
||||
<View style={stylesheet.bottomBarWrapper}>
|
||||
<View style={stylesheet.bottomBarLeft}>{leftContent}</View>
|
||||
<View style={stylesheet.bottomBarRight}>
|
||||
{isVoiceMode ? <VoiceCompactIndicator /> : null}
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -553,14 +557,27 @@ export function AgentStreamView({
|
||||
streamHead,
|
||||
renderStreamItemContent,
|
||||
tightGap,
|
||||
showBottomBar,
|
||||
showCopyButton,
|
||||
latestTurnContent,
|
||||
isVoiceMode,
|
||||
]);
|
||||
|
||||
const flatListExtraData = useMemo(
|
||||
() => ({
|
||||
pendingPermissionCount: pendingPermissionItems.length,
|
||||
showWorkingIndicator,
|
||||
showBottomBar,
|
||||
showCopyButton,
|
||||
isVoiceMode,
|
||||
}),
|
||||
[pendingPermissionItems.length, showWorkingIndicator]
|
||||
[
|
||||
pendingPermissionItems.length,
|
||||
showWorkingIndicator,
|
||||
showBottomBar,
|
||||
showCopyButton,
|
||||
isVoiceMode,
|
||||
]
|
||||
);
|
||||
|
||||
// FlatList's ListHeaderComponent renders at the *bottom* when inverted.
|
||||
@@ -1178,11 +1195,23 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
listHeaderContent: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
workingIndicatorWrapper: {
|
||||
alignItems: "flex-start",
|
||||
bottomBarWrapper: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: 3,
|
||||
paddingRight: 3,
|
||||
paddingTop: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
bottomBarLeft: {
|
||||
flex: 1,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
bottomBarRight: {
|
||||
flexShrink: 0,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
workingIndicatorBubble: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -179,6 +179,7 @@ export function DictationOverlay({
|
||||
isDetecting
|
||||
isSpeaking={false}
|
||||
orientation="horizontal"
|
||||
color={theme.colors.palette.white}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -261,7 +261,6 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
alignSelf: "flex-start",
|
||||
padding: theme.spacing[2],
|
||||
marginLeft: theme.spacing[4],
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
type HostProfile,
|
||||
} from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl, extractHostPortFromWebSocketUrl } from "@/utils/daemon-endpoints";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
function buildCandidateUrls(daemon: HostProfile): string[] {
|
||||
const endpoints = daemon.endpoints ?? [];
|
||||
const sessionId = daemon.relay?.sessionId ?? null;
|
||||
const relayEndpoint = daemon.relay?.endpoint ?? null;
|
||||
const lastKnownGood =
|
||||
typeof daemon.metadata?.lastKnownGoodEndpoint === "string"
|
||||
? (daemon.metadata.lastKnownGoodEndpoint as string)
|
||||
@@ -20,38 +21,26 @@ function buildCandidateUrls(daemon: HostProfile): string[] {
|
||||
if (!out.includes(url)) out.push(url);
|
||||
};
|
||||
|
||||
if (lastKnownGood) {
|
||||
const isLastKnownRelay = !!relayEndpoint && lastKnownGood === relayEndpoint;
|
||||
const directEndpoints = endpoints;
|
||||
|
||||
if (sessionId && relayEndpoint && isLastKnownRelay) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
} else if (lastKnownGood) {
|
||||
push(buildDaemonWebSocketUrl(lastKnownGood));
|
||||
}
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
for (const endpoint of directEndpoints) {
|
||||
push(buildDaemonWebSocketUrl(endpoint));
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
if (lastKnownGood) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: lastKnownGood, sessionId }));
|
||||
}
|
||||
for (const endpoint of endpoints) {
|
||||
push(buildRelayWebSocketUrl({ endpoint, sessionId }));
|
||||
}
|
||||
if (sessionId && relayEndpoint) {
|
||||
push(buildRelayWebSocketUrl({ endpoint: relayEndpoint, sessionId }));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractEndpointFromUrl(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const host = parsed.hostname;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
|
||||
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
const { connectionStates } = useDaemonConnections();
|
||||
const { updateDaemon } = useDaemonRegistry();
|
||||
@@ -86,9 +75,15 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
useEffect(() => {
|
||||
if (!connection) return;
|
||||
|
||||
if (status === "online") {
|
||||
if (pendingMetadataWriteRef.current) return;
|
||||
const endpoint = extractEndpointFromUrl(activeUrl);
|
||||
if (status === "online") {
|
||||
if (pendingMetadataWriteRef.current) return;
|
||||
const endpoint = (() => {
|
||||
try {
|
||||
return extractHostPortFromWebSocketUrl(activeUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!endpoint) return;
|
||||
if (daemon.metadata?.lastKnownGoodEndpoint === endpoint) return;
|
||||
|
||||
@@ -123,7 +118,12 @@ function ManagedDaemonSession({ daemon }: { daemon: HostProfile }) {
|
||||
]);
|
||||
|
||||
return (
|
||||
<SessionProvider key={`${daemon.id}:${activeUrl}`} serverUrl={activeUrl} serverId={daemon.id}>
|
||||
<SessionProvider
|
||||
key={`${daemon.id}:${activeUrl}`}
|
||||
serverUrl={activeUrl}
|
||||
serverId={daemon.id}
|
||||
daemonPublicKeyB64={daemon.daemonPublicKeyB64}
|
||||
>
|
||||
{null}
|
||||
</SessionProvider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { View, Pressable, Text, Platform, Modal, Alert } from "react-native";
|
||||
import { View, Pressable, Text, Platform, Alert } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
@@ -23,6 +23,7 @@ import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||
const SIDEBAR_AGENT_LIMIT = 15;
|
||||
@@ -351,31 +352,31 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
|
||||
<Modal
|
||||
<AdaptiveModalSheet
|
||||
title="Choose a host"
|
||||
visible={showVoiceHostPicker}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={handleDismissVoiceHostPicker}
|
||||
onClose={handleDismissVoiceHostPicker}
|
||||
snapPoints={["45%", "70%"]}
|
||||
testID="voice-host-picker"
|
||||
>
|
||||
<View style={styles.hostPickerOverlay}>
|
||||
<Pressable style={styles.hostPickerBackdrop} onPress={handleDismissVoiceHostPicker} />
|
||||
<View style={styles.hostPickerContainer}>
|
||||
<Text style={styles.hostPickerTitle}>Choose a host</Text>
|
||||
{voiceEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
style={styles.hostPickerButton}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
|
||||
>
|
||||
<Text style={styles.hostPickerButtonText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable style={styles.hostPickerCancel} onPress={handleDismissVoiceHostPicker}>
|
||||
<Text style={styles.hostPickerCancelText}>Cancel</Text>
|
||||
<View style={styles.hostPickerList}>
|
||||
{voiceEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
style={styles.hostPickerOption}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
|
||||
>
|
||||
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
<Pressable
|
||||
style={styles.hostPickerCancel}
|
||||
onPress={handleDismissVoiceHostPicker}
|
||||
>
|
||||
<Text style={styles.hostPickerCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Modal>
|
||||
</AdaptiveModalSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -460,6 +461,32 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<AdaptiveModalSheet
|
||||
title="Choose a host"
|
||||
visible={showVoiceHostPicker}
|
||||
onClose={handleDismissVoiceHostPicker}
|
||||
snapPoints={["45%", "70%"]}
|
||||
testID="voice-host-picker"
|
||||
>
|
||||
<View style={styles.hostPickerList}>
|
||||
{voiceEligibleHosts.map((entry) => (
|
||||
<Pressable
|
||||
key={entry.daemon.id}
|
||||
style={styles.hostPickerOption}
|
||||
onPress={() => handleSelectVoiceHost(entry.daemon.id)}
|
||||
>
|
||||
<Text style={styles.hostPickerOptionText}>{entry.daemon.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
style={styles.hostPickerCancel}
|
||||
onPress={handleDismissVoiceHostPicker}
|
||||
>
|
||||
<Text style={styles.hostPickerCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -547,33 +574,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
footerButtonTextHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
hostPickerOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
hostPickerBackdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
hostPickerContainer: {
|
||||
width: "100%",
|
||||
maxWidth: 360,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
padding: theme.spacing[4],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
hostPickerList: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
hostPickerTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
hostPickerButton: {
|
||||
hostPickerOption: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -581,12 +585,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
hostPickerButtonText: {
|
||||
hostPickerOptionText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
hostPickerCancel: {
|
||||
marginTop: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
|
||||
@@ -208,7 +208,7 @@ export function DropdownMenuTrigger({
|
||||
collapsable={false}
|
||||
disabled={disabled}
|
||||
onPress={handlePress}
|
||||
style={({ pressed, hovered }) => {
|
||||
style={({ pressed, hovered = false }) => {
|
||||
if (typeof style === "function") {
|
||||
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff } from "lucide-react-native";
|
||||
import { VolumeMeter } from "@/components/volume-meter";
|
||||
import { useVoice } from "@/contexts/voice-context";
|
||||
|
||||
export function VoiceCompactIndicator() {
|
||||
const { theme } = useUnistyles();
|
||||
const { isVoiceMode, volume, isMuted, isDetecting, isSpeaking, toggleMute } =
|
||||
useVoice();
|
||||
|
||||
if (!isVoiceMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isMuted && styles.containerMuted]}>
|
||||
<View style={styles.meterContainer}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={toggleMute}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
|
||||
style={styles.muteButton}
|
||||
hitSlop={8}
|
||||
>
|
||||
{isMuted ? (
|
||||
<MicOff size={14} color={theme.colors.palette.white} />
|
||||
) : (
|
||||
<Mic size={14} color={theme.colors.foreground} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: theme.spacing[1],
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
containerMuted: {
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
borderWidth: 0,
|
||||
},
|
||||
meterContainer: {
|
||||
justifyContent: "center",
|
||||
},
|
||||
muteButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 0,
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { View, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { MicOff, Square, AudioLines } from "lucide-react-native";
|
||||
import { MicOff, Square } from "lucide-react-native";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
import { useVoice } from "@/contexts/voice-context";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
@@ -20,55 +20,47 @@ export function VoicePanel() {
|
||||
|
||||
const activeHost = activeServerId ? connectionStates.get(activeServerId) ?? null : null;
|
||||
const hostLabel = activeHost?.daemon.label ?? null;
|
||||
const hostStatus = activeHost?.status ?? null;
|
||||
const hostSuffix = hostLabel ? ` (${hostLabel})` : "";
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.headerRow}>
|
||||
<View style={styles.titleRow}>
|
||||
<AudioLines size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.titleText}>Voice</Text>
|
||||
</View>
|
||||
<Text style={styles.hostText} numberOfLines={1}>
|
||||
{hostLabel ? `Host: ${hostLabel}` : "Host: unknown"}
|
||||
{hostStatus ? ` (${hostStatus})` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.meterRow}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionsRow}>
|
||||
<Pressable
|
||||
onPress={toggleMute}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"}
|
||||
style={[
|
||||
styles.iconButton,
|
||||
isMuted && styles.iconButtonMuted,
|
||||
]}
|
||||
>
|
||||
<MicOff
|
||||
size={18}
|
||||
color={isMuted ? theme.colors.surface0 : theme.colors.foreground}
|
||||
<View style={styles.contentRow}>
|
||||
<View style={styles.meterContainer}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isDetecting={isDetecting}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => void stopVoice()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Stop voice mode"
|
||||
style={[styles.iconButton, styles.iconButtonStop]}
|
||||
>
|
||||
<Square size={16} color="white" fill="white" />
|
||||
</Pressable>
|
||||
<View style={styles.actionsRow}>
|
||||
<Pressable
|
||||
onPress={toggleMute}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`${isMuted ? "Unmute voice" : "Mute voice"}${hostSuffix}`}
|
||||
style={[
|
||||
styles.iconButton,
|
||||
isMuted && styles.iconButtonMuted,
|
||||
]}
|
||||
>
|
||||
<MicOff
|
||||
size={18}
|
||||
color={isMuted ? theme.colors.palette.white : theme.colors.foreground}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={() => void stopVoice()}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Stop voice mode${hostSuffix}`}
|
||||
style={[styles.iconButton, styles.iconButtonStop]}
|
||||
>
|
||||
<Square size={16} color="white" fill="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -82,35 +74,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
headerRow: {
|
||||
contentRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
titleText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
hostText: {
|
||||
meterContainer: {
|
||||
flex: 1,
|
||||
textAlign: "right",
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
meterRow: {
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-start",
|
||||
paddingLeft: theme.spacing[1],
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: "row",
|
||||
@@ -130,7 +107,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
iconButtonMuted: {
|
||||
backgroundColor: theme.colors.palette.red[500],
|
||||
borderColor: theme.colors.palette.red[600],
|
||||
borderWidth: 0,
|
||||
},
|
||||
iconButtonStop: {
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import ReanimatedAnimated, {
|
||||
useSharedValue,
|
||||
@@ -17,16 +17,29 @@ interface VolumeMeterProps {
|
||||
isDetecting?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
variant?: "default" | "compact";
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSpeaking = false, orientation = "vertical" }: VolumeMeterProps) {
|
||||
export function VolumeMeter({
|
||||
volume,
|
||||
isMuted = false,
|
||||
isDetecting = false,
|
||||
isSpeaking = false,
|
||||
orientation = "vertical",
|
||||
variant = "default",
|
||||
color,
|
||||
}: VolumeMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isCompact = variant === "compact";
|
||||
|
||||
// Base dimensions
|
||||
const LINE_SPACING = 8;
|
||||
const LINE_WIDTH = 8;
|
||||
const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50;
|
||||
const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20;
|
||||
const LINE_SPACING = isCompact ? 6 : 8;
|
||||
const LINE_WIDTH = isCompact ? 6 : 8;
|
||||
const MAX_HEIGHT =
|
||||
orientation === "horizontal" ? (isCompact ? 18 : 30) : isCompact ? 32 : 50;
|
||||
const MIN_HEIGHT =
|
||||
orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20;
|
||||
|
||||
// Create shared values for 3 dots unconditionally
|
||||
const line1Height = useSharedValue(MIN_HEIGHT);
|
||||
@@ -127,8 +140,9 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
|
||||
}
|
||||
}, [volume, isMuted]);
|
||||
|
||||
const lineColor = "#FFFFFF";
|
||||
const containerHeight = orientation === "horizontal" ? 60 : 100;
|
||||
const lineColor = color ?? theme.colors.foreground;
|
||||
const containerHeight =
|
||||
orientation === "horizontal" ? (isCompact ? 32 : 60) : isCompact ? 64 : 100;
|
||||
|
||||
// Create animated styles unconditionally at top level
|
||||
const line1Style = useAnimatedStyle(() => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createContext, useCallback, useContext } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
decodeOfferFragmentPayload,
|
||||
@@ -10,6 +9,10 @@ import {
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
ConnectionOfferV1Schema,
|
||||
type ConnectionOfferV1,
|
||||
} from "@server/shared/connection-offer";
|
||||
|
||||
const REGISTRY_STORAGE_KEY = "@paseo:daemon-registry";
|
||||
const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
||||
@@ -41,15 +44,6 @@ type CreateHostInput = {
|
||||
|
||||
type UpdateHostInput = Partial<Omit<HostProfile, "id" | "createdAt">>;
|
||||
|
||||
const ConnectionOfferV1Schema = z.object({
|
||||
v: z.literal(1),
|
||||
sessionId: z.string().min(1),
|
||||
endpoints: z.array(z.string().min(1)).min(1),
|
||||
daemonPublicKeyB64: z.string().min(1),
|
||||
});
|
||||
|
||||
export type ConnectionOfferV1 = z.infer<typeof ConnectionOfferV1Schema>;
|
||||
|
||||
interface DaemonRegistryContextValue {
|
||||
daemons: HostProfile[];
|
||||
isLoading: boolean;
|
||||
@@ -201,7 +195,16 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
const existing = readDaemons();
|
||||
const now = new Date().toISOString();
|
||||
const normalizedEndpoints = offer.endpoints.map((endpoint) => normalizeHostPort(endpoint));
|
||||
const relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
|
||||
let relayEndpoint: string | null = null;
|
||||
if (offer.relay?.endpoint) {
|
||||
relayEndpoint = normalizeHostPort(offer.relay.endpoint);
|
||||
} else if (offer.relay === undefined && normalizedEndpoints.length > 0) {
|
||||
// Back-compat: older offers encoded relay endpoint as the last entry.
|
||||
relayEndpoint = normalizedEndpoints[normalizedEndpoints.length - 1];
|
||||
}
|
||||
const relay = relayEndpoint
|
||||
? { endpoint: relayEndpoint, sessionId: offer.sessionId }
|
||||
: null;
|
||||
|
||||
const matchIndex = existing.findIndex((daemon) => daemon.daemonPublicKeyB64 === offer.daemonPublicKeyB64);
|
||||
if (matchIndex !== -1) {
|
||||
@@ -209,7 +212,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
...existing[matchIndex],
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
endpoints: normalizedEndpoints,
|
||||
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
|
||||
relay,
|
||||
updatedAt: now,
|
||||
};
|
||||
const next = [...existing];
|
||||
@@ -223,7 +226,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
|
||||
label: deriveLabelFromEndpoint(normalizedEndpoints[0] ?? "Unnamed Host"),
|
||||
endpoints: normalizedEndpoints,
|
||||
daemonPublicKeyB64: offer.daemonPublicKeyB64,
|
||||
relay: { endpoint: relayEndpoint, sessionId: offer.sessionId },
|
||||
relay,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
metadata: null,
|
||||
|
||||
@@ -258,6 +258,7 @@ interface SessionProviderProps {
|
||||
children: ReactNode;
|
||||
serverUrl: string;
|
||||
serverId: string;
|
||||
daemonPublicKeyB64?: string;
|
||||
}
|
||||
|
||||
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
|
||||
@@ -268,9 +269,10 @@ export function SessionProvider({
|
||||
children,
|
||||
serverUrl,
|
||||
serverId,
|
||||
daemonPublicKeyB64,
|
||||
}: SessionProviderProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useDaemonClient(serverUrl);
|
||||
const client = useDaemonClient(serverUrl, { daemonPublicKeyB64 });
|
||||
const [connectionSnapshot, setConnectionSnapshot] =
|
||||
useState<DaemonConnectionSnapshot>(() =>
|
||||
mapConnectionState(client.getConnectionState(), client.lastError)
|
||||
|
||||
@@ -9,7 +9,14 @@ function runDaemonRequest(label: string, promise: Promise<unknown>): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDaemonClient(url: string): DaemonClient {
|
||||
type DaemonClientOptions = {
|
||||
daemonPublicKeyB64?: string;
|
||||
};
|
||||
|
||||
export function useDaemonClient(
|
||||
url: string,
|
||||
options: DaemonClientOptions = {}
|
||||
): DaemonClient {
|
||||
const client = useMemo(
|
||||
() => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
@@ -19,9 +26,15 @@ export function useDaemonClient(url: string): DaemonClient {
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
e2ee: options.daemonPublicKeyB64
|
||||
? {
|
||||
enabled: true,
|
||||
daemonPublicKeyB64: options.daemonPublicKeyB64,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
[url]
|
||||
[options.daemonPublicKeyB64, url]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,16 +7,44 @@ declare global {
|
||||
}
|
||||
|
||||
export function polyfillCrypto(): void {
|
||||
if (typeof globalThis.crypto === "undefined") {
|
||||
(globalThis as any).crypto = {};
|
||||
let webcrypto: Crypto | null = null;
|
||||
try {
|
||||
// Prefer the React Native entrypoint to avoid the browser shim.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const rnModule = require("@sphereon/isomorphic-webcrypto/src/react-native");
|
||||
webcrypto = (rnModule?.default ?? rnModule) as Crypto;
|
||||
} catch {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fallbackModule = require("@sphereon/isomorphic-webcrypto");
|
||||
webcrypto = (fallbackModule?.default ?? fallbackModule) as Crypto;
|
||||
}
|
||||
|
||||
if (typeof globalThis.crypto.randomUUID !== "function") {
|
||||
const existing = (globalThis as any).crypto as Crypto | null | undefined;
|
||||
let target = existing;
|
||||
if (!target || typeof (target as Crypto).subtle === "undefined") {
|
||||
target = (webcrypto && typeof webcrypto === "object" ? (webcrypto as Crypto) : undefined) ?? ({} as Crypto);
|
||||
(globalThis as any).crypto = target;
|
||||
}
|
||||
|
||||
const ensureSecure = (globalThis as any).crypto?.ensureSecure as
|
||||
| (() => Promise<void>)
|
||||
| undefined;
|
||||
if (typeof ensureSecure === "function") {
|
||||
void ensureSecure();
|
||||
}
|
||||
|
||||
if (typeof (globalThis as any).crypto?.randomUUID !== "function") {
|
||||
if (!globalThis.crypto) {
|
||||
(globalThis as any).crypto = {} as Crypto;
|
||||
}
|
||||
globalThis.crypto.randomUUID = () =>
|
||||
ExpoCrypto.randomUUID() as `${string}-${string}-${string}-${string}-${string}`;
|
||||
}
|
||||
|
||||
if (typeof globalThis.crypto.getRandomValues !== "function") {
|
||||
if (typeof (globalThis as any).crypto?.getRandomValues !== "function") {
|
||||
if (!globalThis.crypto) {
|
||||
(globalThis as any).crypto = {} as Crypto;
|
||||
}
|
||||
globalThis.crypto.getRandomValues = <T extends ArrayBufferView>(array: T): T => {
|
||||
return ExpoCrypto.getRandomValues(array as any) as T;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { Buffer } from "buffer";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl as buildSharedRelayWebSocketUrl,
|
||||
deriveLabelFromEndpoint,
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseHostPort,
|
||||
type HostPortParts,
|
||||
} from "@server/shared/daemon-endpoints";
|
||||
|
||||
export type HostPortParts = {
|
||||
host: string;
|
||||
port: number;
|
||||
isIpv6: boolean;
|
||||
export type { HostPortParts };
|
||||
|
||||
export {
|
||||
buildDaemonWebSocketUrl,
|
||||
deriveLabelFromEndpoint,
|
||||
extractHostPortFromWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseHostPort,
|
||||
};
|
||||
|
||||
function decodeBase64UrlToUtf8(input: string): string {
|
||||
@@ -17,98 +30,6 @@ export function decodeOfferFragmentPayload(encoded: string): unknown {
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
|
||||
export function parseHostPort(input: string): HostPortParts {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Host is required");
|
||||
}
|
||||
|
||||
// IPv6: [::1]:6767
|
||||
if (trimmed.startsWith("[")) {
|
||||
const match = trimmed.match(/^\[([^\]]+)\]:(\d{1,5})$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid host:port (expected [::1]:6767)");
|
||||
}
|
||||
const host = match[1].trim();
|
||||
const port = Number(match[2]);
|
||||
if (!host) throw new Error("Host is required");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
return { host, port, isIpv6: true };
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^(.+):(\d{1,5})$/);
|
||||
if (!match) {
|
||||
throw new Error("Invalid host:port (expected localhost:6767)");
|
||||
}
|
||||
const host = match[1].trim();
|
||||
const port = Number(match[2]);
|
||||
if (!host) throw new Error("Host is required");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Port must be between 1 and 65535");
|
||||
}
|
||||
return { host, port, isIpv6: false };
|
||||
}
|
||||
|
||||
export function normalizeHostPort(input: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(input);
|
||||
if (isIpv6) {
|
||||
return `[${host}]:${port}`;
|
||||
}
|
||||
return `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function extractHostPortFromWebSocketUrl(wsUrl: string): string {
|
||||
const parsed = new URL(wsUrl);
|
||||
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
||||
throw new Error("Invalid WebSocket URL protocol");
|
||||
}
|
||||
if (parsed.pathname.replace(/\/+$/, "") !== "/ws") {
|
||||
throw new Error("Invalid WebSocket URL (expected /ws path)");
|
||||
}
|
||||
|
||||
const host = parsed.hostname;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === "wss:" ? 443 : 80;
|
||||
if (!host) {
|
||||
throw new Error("Invalid WebSocket URL (missing hostname)");
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error("Invalid WebSocket URL (invalid port)");
|
||||
}
|
||||
|
||||
const isIpv6 = host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||
const hostPort = isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
return hostPort;
|
||||
}
|
||||
|
||||
function shouldUseSecureWebSocket(port: number): boolean {
|
||||
return port === 443;
|
||||
}
|
||||
|
||||
export function buildDaemonWebSocketUrl(endpoint: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
return `${protocol}://${hostPart}:${port}/ws`;
|
||||
}
|
||||
|
||||
export function buildRelayWebSocketUrl(params: { endpoint: string; sessionId: string }): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(params.endpoint);
|
||||
const protocol = shouldUseSecureWebSocket(port) ? "wss" : "ws";
|
||||
const hostPart = isIpv6 ? `[${host}]` : host;
|
||||
const url = new URL(`${protocol}://${hostPart}:${port}/ws`);
|
||||
url.searchParams.set("session", params.sessionId);
|
||||
url.searchParams.set("role", "client");
|
||||
return url.toString();
|
||||
return buildSharedRelayWebSocketUrl({ ...params, role: "client" });
|
||||
}
|
||||
|
||||
export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const { host } = parseHostPort(endpoint);
|
||||
return host || "Unnamed Host";
|
||||
} catch {
|
||||
return "Unnamed Host";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user