feat: persist draft messages and UI refinements
- Add dedicated draft-store with AsyncStorage persistence for drafts that survive app refresh/crash - Reduce markdown bold weight from bold to medium (500) - Various theme and component style updates - Server bootstrap and config improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Generated
+27
-1
@@ -11,7 +11,8 @@
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
"packages/app"
|
||||
"packages/app",
|
||||
"packages/relay"
|
||||
],
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.1.75"
|
||||
@@ -1607,6 +1608,13 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@cloudflare/workers-types": {
|
||||
"version": "4.20260113.0",
|
||||
"resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260113.0.tgz",
|
||||
"integrity": "sha512-CS2tUdGn1EMAV5GoFYYUfsZ4vwwXiYxwrUiI8ZRkxrJGqkHNGily/5Zf+vt/wh1HSoiCIChNYiuLEoCA/XUybw==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@@ -5965,6 +5973,10 @@
|
||||
"resolved": "packages/app",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@paseo/relay": {
|
||||
"resolved": "packages/relay",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@paseo/server": {
|
||||
"resolved": "packages/server",
|
||||
"link": true
|
||||
@@ -21627,6 +21639,20 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@paseo/relay",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/ws": "^8.5.8",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@paseo/server",
|
||||
"version": "0.1.0",
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/server",
|
||||
"packages/app"
|
||||
"packages/app",
|
||||
"packages/relay"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev --workspace=@paseo/server",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RealtimeProvider } from "@/contexts/realtime-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { View, ActivityIndicator, Text } from "react-native";
|
||||
import { UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
|
||||
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
|
||||
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
|
||||
@@ -144,7 +145,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.background }}>
|
||||
<View style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
|
||||
<View style={{ flex: 1, flexDirection: "row" }}>
|
||||
{!isMobile && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||
<View style={{ flex: 1 }}>{children}</View>
|
||||
@@ -165,10 +166,21 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
}
|
||||
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { isLoading: settingsLoading } = useAppSettings();
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { daemons, isLoading: registryLoading } = useDaemonRegistry();
|
||||
const isLoading = settingsLoading || registryLoading;
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (settings.theme === "auto") {
|
||||
UnistylesRuntime.setAdaptiveThemes(true);
|
||||
} else {
|
||||
UnistylesRuntime.setAdaptiveThemes(false);
|
||||
UnistylesRuntime.setTheme(settings.theme);
|
||||
}
|
||||
}, [isLoading, settings.theme]);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingView />;
|
||||
}
|
||||
@@ -207,10 +219,10 @@ function LoadingView() {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "#09090b",
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color="#fafafa" />
|
||||
<ActivityIndicator size="large" color={darkTheme.colors.foreground} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -223,13 +235,13 @@ function MissingDaemonView() {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
padding: 24,
|
||||
backgroundColor: "#09090b",
|
||||
backgroundColor: darkTheme.colors.surface0,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="small" color="#fafafa" />
|
||||
<ActivityIndicator size="small" color={darkTheme.colors.foreground} />
|
||||
<Text
|
||||
style={{
|
||||
color: "#fafafa",
|
||||
color: darkTheme.colors.foreground,
|
||||
marginTop: 16,
|
||||
textAlign: "center",
|
||||
}}
|
||||
|
||||
@@ -149,7 +149,7 @@ export default function LegacyAgentRedirectScreen() {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
@@ -170,7 +170,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
primaryButton: {
|
||||
@@ -194,7 +194,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
matchLabel: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -204,6 +204,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
matchMeta: {
|
||||
marginTop: theme.spacing[1],
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -644,7 +644,7 @@ function AgentScreenContent({
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -653,14 +653,14 @@ function AgentScreenContent({
|
||||
color={
|
||||
isExplorerOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
<MoreVertical size={16} color={theme.colors.mutedForeground} />
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
@@ -748,7 +748,7 @@ function AgentScreenContent({
|
||||
<>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={theme.colors.mutedForeground}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text style={styles.menuMetaPendingText}>
|
||||
Fetching…
|
||||
@@ -777,7 +777,7 @@ function AgentScreenContent({
|
||||
{/* Sub-Agents Section */}
|
||||
<View style={styles.menuSubAgentsSection}>
|
||||
<View style={styles.menuSubAgentsHeader}>
|
||||
<Users size={16} color={theme.colors.mutedForeground} />
|
||||
<Users size={16} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.menuSubAgentsLabel}>Sub-Agents</Text>
|
||||
</View>
|
||||
{childAgents.length === 0 ? (
|
||||
@@ -807,7 +807,7 @@ function AgentScreenContent({
|
||||
) : null}
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color={theme.colors.mutedForeground}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -953,11 +953,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
outerContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
headerRightContent: {
|
||||
flexDirection: "row",
|
||||
@@ -978,7 +978,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
centerState: {
|
||||
flex: 1,
|
||||
@@ -994,20 +994,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
statusText: {
|
||||
marginTop: theme.spacing[2],
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
errorDetails: {
|
||||
marginTop: theme.spacing[1],
|
||||
textAlign: "center",
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
offlineTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -1017,12 +1017,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
offlineDescription: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
offlineDetails: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
menuButton: {
|
||||
@@ -1036,7 +1036,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
dropdownMenu: {
|
||||
backgroundColor: theme.colors.popover,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[2],
|
||||
shadowColor: "#000",
|
||||
@@ -1054,7 +1054,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
menuMetaLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
@@ -1069,7 +1069,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
menuMetaPendingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
menuMetaValueError: {
|
||||
color: theme.colors.destructive,
|
||||
@@ -1110,13 +1110,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
menuSubAgentsLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
menuSubAgentsEmpty: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
|
||||
@@ -46,6 +46,6 @@ export default function AgentsScreen() {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -11,7 +11,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.palette.white,
|
||||
},
|
||||
scrollViewDark: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
container: {
|
||||
padding: theme.spacing[6],
|
||||
@@ -31,7 +31,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[8],
|
||||
},
|
||||
subtitleDark: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
marginBottom: theme.spacing[8],
|
||||
@@ -83,7 +83,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
statusCardDark: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
statusLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -92,7 +92,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
statusLabelDark: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
statusValue: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -146,7 +146,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.palette.gray[300],
|
||||
},
|
||||
buttonDisabledDark: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -157,7 +157,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.palette.gray[500],
|
||||
},
|
||||
buttonTextDisabledDark: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
fullWidthButton: {
|
||||
padding: theme.spacing[4],
|
||||
@@ -202,7 +202,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
configCardDark: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
configTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -211,7 +211,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
configTitleDark: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
configText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -219,7 +219,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
configTextDark: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
gap3: {
|
||||
gap: theme.spacing[3],
|
||||
|
||||
@@ -538,7 +538,7 @@ export default function HomeScreen() {
|
||||
style={styles.hostBadge}
|
||||
onPress={() => openDropdownSheet("host")}
|
||||
>
|
||||
<Monitor size={14} color={theme.colors.mutedForeground} />
|
||||
<Monitor size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.hostBadgeLabel}>{hostLabel}</Text>
|
||||
<View
|
||||
style={[
|
||||
@@ -671,7 +671,7 @@ export default function HomeScreen() {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
agentPanel: {
|
||||
flex: 1,
|
||||
@@ -684,7 +684,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
inputAreaWrapper: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
configScrollContent: {
|
||||
flexGrow: 1,
|
||||
@@ -701,7 +701,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
dropdownHelper: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
dropdownSheetList: {
|
||||
marginTop: theme.spacing[3],
|
||||
@@ -712,7 +712,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
dropdownSheetOptionSelected: {
|
||||
@@ -724,7 +724,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
dropdownSheetOptionDescription: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
@@ -757,11 +757,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
hostBadgeLabel: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
@@ -769,7 +769,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.mutedForeground,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
hostStatusDotOnline: {
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function OrchestratorScreen() {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { router } from "expo-router";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Sun, Moon, Monitor } from "lucide-react-native";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useDaemonRegistry, type DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useDaemonConnections, type ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
|
||||
@@ -34,7 +35,7 @@ const delay = (ms: number) =>
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
@@ -44,7 +45,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
@@ -57,7 +58,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[6],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
letterSpacing: 0.6,
|
||||
@@ -66,7 +67,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
letterSpacing: 0.4,
|
||||
@@ -74,7 +75,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -87,7 +88,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Host card styles
|
||||
hostCard: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -109,7 +110,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
hostUrl: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
@@ -151,7 +152,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
hostActionText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
@@ -189,13 +190,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderStyle: "dashed",
|
||||
},
|
||||
addButtonText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
// Add/Edit form
|
||||
formCard: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -237,7 +238,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Audio settings card
|
||||
audioCard: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -263,7 +264,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
audioRowDescription: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: 2,
|
||||
},
|
||||
@@ -281,11 +282,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footerText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
footerVersion: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
resetButton: {
|
||||
@@ -298,7 +299,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Empty state
|
||||
emptyCard: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
@@ -306,10 +307,39 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
// Theme toggle
|
||||
themeToggleContainer: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: 4,
|
||||
gap: 4,
|
||||
},
|
||||
themeToggleButton: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
themeToggleButtonActive: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
themeToggleText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
themeToggleTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
type DaemonTestState = {
|
||||
@@ -502,6 +532,19 @@ export default function SettingsScreen() {
|
||||
[settings, updateSettings]
|
||||
);
|
||||
|
||||
const handleThemeChange = useCallback(
|
||||
(newTheme: AppSettings["theme"]) => {
|
||||
void updateSettings({ theme: newTheme });
|
||||
if (newTheme === "auto") {
|
||||
UnistylesRuntime.setAdaptiveThemes(true);
|
||||
} else {
|
||||
UnistylesRuntime.setAdaptiveThemes(false);
|
||||
UnistylesRuntime.setTheme(newTheme);
|
||||
}
|
||||
},
|
||||
[updateSettings]
|
||||
);
|
||||
|
||||
function validateServerUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -637,6 +680,49 @@ export default function SettingsScreen() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Appearance */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Appearance</Text>
|
||||
<View style={styles.themeToggleContainer}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "light" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("light")}
|
||||
>
|
||||
<Sun size={16} color={settings.theme === "light" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "light" && styles.themeToggleTextActive]}>
|
||||
Light
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "dark" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("dark")}
|
||||
>
|
||||
<Moon size={16} color={settings.theme === "dark" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "dark" && styles.themeToggleTextActive]}>
|
||||
Dark
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.themeToggleButton,
|
||||
settings.theme === "auto" && styles.themeToggleButtonActive,
|
||||
]}
|
||||
onPress={() => handleThemeChange("auto")}
|
||||
>
|
||||
<Monitor size={16} color={settings.theme === "auto" ? defaultTheme.colors.foreground : defaultTheme.colors.mutedForeground} />
|
||||
<Text style={[styles.themeToggleText, settings.theme === "auto" && styles.themeToggleTextActive]}>
|
||||
System
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Audio Settings */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Audio</Text>
|
||||
@@ -727,7 +813,7 @@ function DaemonCard({
|
||||
? theme.colors.palette.amber[500]
|
||||
: statusTone === "error"
|
||||
? theme.colors.destructive
|
||||
: theme.colors.mutedForeground;
|
||||
: theme.colors.foregroundMuted;
|
||||
const badgeText = statusLabel;
|
||||
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
|
||||
const daemonConnection = useSessionStore(
|
||||
@@ -870,7 +956,7 @@ function DaemonCard({
|
||||
? theme.colors.palette.green[400]
|
||||
: testState?.status === "error"
|
||||
? theme.colors.palette.red[300]
|
||||
: theme.colors.mutedForeground;
|
||||
: theme.colors.foregroundMuted;
|
||||
|
||||
return (
|
||||
<View style={styles.hostCard}>
|
||||
@@ -911,7 +997,7 @@ function DaemonCard({
|
||||
disabled={isRestarting}
|
||||
>
|
||||
{isRestarting ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.mutedForeground} />
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Text style={styles.hostActionText}>Restart</Text>
|
||||
)}
|
||||
|
||||
@@ -46,7 +46,7 @@ function getModeColor(modeId?: string): string {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
@@ -57,7 +57,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
backButton: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -87,7 +87,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.primary,
|
||||
},
|
||||
processItemInactive: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
agentIcon: {
|
||||
width: 12,
|
||||
|
||||
@@ -252,19 +252,19 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) {
|
||||
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
card: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[3],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
thoughtCard: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: theme.colors.palette.purple[500],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
timestamp: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
@@ -283,11 +283,11 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
lineHeight: 20,
|
||||
},
|
||||
thoughtText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
toolCard: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: 'hidden',
|
||||
@@ -327,7 +327,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
expandIcon: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
toolContent: {
|
||||
@@ -345,20 +345,20 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
code: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
planCard: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: 'hidden',
|
||||
@@ -399,11 +399,11 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
infoText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
unknownCard: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: 'hidden',
|
||||
@@ -428,6 +428,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
padding: theme.spacing[3],
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -87,7 +87,7 @@ export function DropdownField({
|
||||
>
|
||||
{value || placeholder}
|
||||
</Text>
|
||||
<ChevronDown size={16} color={defaultTheme.colors.mutedForeground} />
|
||||
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -138,7 +138,7 @@ export function SelectField({
|
||||
{value || placeholder || "Select..."}
|
||||
</Text>
|
||||
</View>
|
||||
<ChevronRight size={20} color={defaultTheme.colors.mutedForeground} />
|
||||
<ChevronRight size={20} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
|
||||
@@ -404,7 +404,7 @@ export function ComboSelect({
|
||||
ref={inputRef}
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
@@ -486,7 +486,7 @@ function CompactSelectField({
|
||||
>
|
||||
<Text style={styles.compactSelectLabel}>{label}</Text>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.mutedForeground} />
|
||||
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Text
|
||||
style={value ? styles.compactSelectValue : styles.compactSelectPlaceholder}
|
||||
@@ -933,7 +933,7 @@ export function WorkingDirectoryDropdown({
|
||||
ref={inputRef}
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
@@ -1132,20 +1132,20 @@ export function GitOptionsSection({
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholder="branch name"
|
||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
onSubmitEditing={handleConfirmEdit}
|
||||
/>
|
||||
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<Check size={16} color={defaultTheme.colors.palette.green[500]} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
|
||||
<X size={16} color={defaultTheme.colors.mutedForeground} />
|
||||
<X size={16} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
|
||||
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
|
||||
<Pencil size={14} color={defaultTheme.colors.mutedForeground} />
|
||||
<Pencil size={14} color={defaultTheme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -1179,7 +1179,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -1196,14 +1196,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
dropdownPlaceholder: {
|
||||
flex: 1,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
dropdownSearchInput: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
color: theme.colors.foreground,
|
||||
@@ -1222,7 +1222,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.45,
|
||||
},
|
||||
dropdownSheetContainer: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
paddingTop: theme.spacing[4],
|
||||
@@ -1259,7 +1259,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
dropdownSheetOptionSelected: {
|
||||
@@ -1271,7 +1271,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
dropdownSheetOptionDescription: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
@@ -1288,7 +1288,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
helperText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
selectorColumn: {
|
||||
@@ -1339,7 +1339,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -1359,7 +1359,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
selectFieldControl: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -1374,7 +1374,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
selectFieldLabel: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
textTransform: "uppercase",
|
||||
@@ -1385,7 +1385,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
selectFieldPlaceholder: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
gitOptionsContainer: {
|
||||
@@ -1395,7 +1395,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -1415,7 +1415,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
worktreeToggleDescription: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
baseBranchRow: {
|
||||
@@ -1425,7 +1425,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
},
|
||||
baseBranchLabel: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
baseBranchValueRow: {
|
||||
@@ -1446,7 +1446,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
baseBranchInput: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -1469,7 +1469,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
},
|
||||
desktopDropdownContainer: {
|
||||
backgroundColor: theme.colors.popover,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1494,7 +1494,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
compactSelectControl: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -1506,7 +1506,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
compactSelectLabel: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
textTransform: "uppercase",
|
||||
@@ -1517,7 +1517,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
compactSelectPlaceholder: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AgentStatusBar } from "./agent-status-bar";
|
||||
import { RealtimeControls } from "./realtime-controls";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import {
|
||||
MessageInput,
|
||||
type MessagePayload,
|
||||
@@ -86,8 +87,8 @@ export function AgentInputArea({
|
||||
state.sessions[serverId]?.agents?.get(agentId)
|
||||
);
|
||||
|
||||
const getDraftInput = useSessionStore((state) => state.getDraftInput);
|
||||
const saveDraftInput = useSessionStore((state) => state.saveDraftInput);
|
||||
const getDraftInput = useDraftStore((state) => state.getDraftInput);
|
||||
const saveDraftInput = useDraftStore((state) => state.saveDraftInput);
|
||||
|
||||
const queuedMessagesRaw = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.queuedMessages?.get(agentId)
|
||||
@@ -639,7 +640,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
@@ -661,7 +662,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
queueSendButton: {
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
|
||||
@@ -94,7 +94,7 @@ export function AgentList({
|
||||
const isRunning = agent.status === "running";
|
||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusColor = isRunning ? "#3b82f6" : agent.requiresAttention ? "#22c55e" : null;
|
||||
const statusColor = isRunning ? theme.colors.accent : agent.requiresAttention ? theme.colors.success : null;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -162,8 +162,8 @@ export function AgentList({
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={theme.colors.mutedForeground}
|
||||
colors={[theme.colors.mutedForeground]}
|
||||
tintColor={theme.colors.foregroundMuted}
|
||||
colors={[theme.colors.foregroundMuted]}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -225,19 +225,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
agentItem: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[2],
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
agentItemSelected: {
|
||||
backgroundColor: theme.colors.palette.zinc[800],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
agentItemHovered: {
|
||||
backgroundColor: theme.colors.palette.zinc[850],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
agentItemPressed: {
|
||||
backgroundColor: theme.colors.palette.zinc[800],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
agentContent: {
|
||||
flex: 1,
|
||||
@@ -255,7 +255,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
xs: "400",
|
||||
md: "300",
|
||||
},
|
||||
color: theme.colors.palette.zinc[200],
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
agentTitleHighlighted: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -263,7 +263,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
secondaryRow: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "300",
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
@@ -283,7 +283,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "rgba(0,0,0,0.35)",
|
||||
},
|
||||
sheetContainer: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
@@ -296,7 +296,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 40,
|
||||
height: 4,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sheetTitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
@@ -305,7 +305,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
sheetSubtitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
sheetButton: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -324,7 +324,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
opacity: 0.5,
|
||||
},
|
||||
sheetCancelButton: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sheetCancelText: {
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -52,7 +52,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
agent.currentModeId ||
|
||||
"default"}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.mutedForeground} />
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
@@ -76,17 +76,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
modeBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.colors.popover,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
modeBadgePressed: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
modeBadgeText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
textTransform: "capitalize",
|
||||
|
||||
@@ -85,9 +85,7 @@ export function AgentStreamView({
|
||||
const [isNearBottom, setIsNearBottom] = useState(true);
|
||||
const hasScrolledInitially = useRef(false);
|
||||
const hasAutoScrolledOnce = useRef(false);
|
||||
const isProgrammaticScrollRef = useRef(false);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const isUserScrollingRef = useRef(false);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const { open: openExplorer, setActiveTab: setExplorerTab } =
|
||||
useExplorerSidebarStore();
|
||||
@@ -160,42 +158,17 @@ export function AgentStreamView({
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset } = event.nativeEvent;
|
||||
const threshold = Math.max(insets.bottom, 32);
|
||||
// In inverted list: scrollTop 0 = bottom, higher values = scrolled up
|
||||
const nearBottom = contentOffset.y <= threshold;
|
||||
|
||||
if (isProgrammaticScrollRef.current) {
|
||||
if (nearBottom && !isNearBottomRef.current) {
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
}
|
||||
return;
|
||||
if (isNearBottomRef.current !== nearBottom) {
|
||||
isNearBottomRef.current = nearBottom;
|
||||
setIsNearBottom(nearBottom);
|
||||
}
|
||||
|
||||
if (!nearBottom && !isUserScrollingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNearBottomRef.current === nearBottom) {
|
||||
return;
|
||||
}
|
||||
|
||||
isNearBottomRef.current = nearBottom;
|
||||
setIsNearBottom(nearBottom);
|
||||
},
|
||||
[insets.bottom]
|
||||
);
|
||||
|
||||
const handleScrollBeginDrag = useCallback(() => {
|
||||
isUserScrollingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleMomentumScrollBegin = useCallback(() => {
|
||||
isUserScrollingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleScrollEnd = useCallback(() => {
|
||||
isUserScrollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const scrollToBottomInternal = useCallback(
|
||||
({ animated }: { animated: boolean }) => {
|
||||
const list = flatListRef.current;
|
||||
@@ -203,19 +176,9 @@ export function AgentStreamView({
|
||||
return;
|
||||
}
|
||||
|
||||
isProgrammaticScrollRef.current = true;
|
||||
list.scrollToOffset({ offset: 0, animated });
|
||||
if (!animated) {
|
||||
isProgrammaticScrollRef.current = false;
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
isProgrammaticScrollRef.current = false;
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
}, 300);
|
||||
}
|
||||
isNearBottomRef.current = true;
|
||||
setIsNearBottom(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -582,10 +545,6 @@ export function AgentStreamView({
|
||||
}}
|
||||
style={stylesheet.list}
|
||||
onScroll={handleScroll}
|
||||
onScrollBeginDrag={handleScrollBeginDrag}
|
||||
onScrollEndDrag={handleScrollEnd}
|
||||
onMomentumScrollBegin={handleMomentumScrollBegin}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
scrollEventThrottle={16}
|
||||
ListEmptyComponent={
|
||||
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
|
||||
@@ -1005,7 +964,7 @@ function PermissionRequestCard({
|
||||
style={[
|
||||
permissionStyles.container,
|
||||
{
|
||||
backgroundColor: theme.colors.secondary,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
@@ -1020,7 +979,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.description,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
{description}
|
||||
@@ -1032,7 +991,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.sectionTitle,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Proposed Plan
|
||||
@@ -1041,7 +1000,7 @@ function PermissionRequestCard({
|
||||
style={[
|
||||
permissionStyles.contentCard,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
@@ -1058,7 +1017,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.sectionTitle,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Command
|
||||
@@ -1068,7 +1027,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.metadataLabel,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Command
|
||||
@@ -1088,7 +1047,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.metadataLabel,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Directory
|
||||
@@ -1111,7 +1070,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.sectionTitle,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Proposed Changes
|
||||
@@ -1127,7 +1086,7 @@ function PermissionRequestCard({
|
||||
permissionStyles.fileBadge,
|
||||
{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -1146,7 +1105,7 @@ function PermissionRequestCard({
|
||||
permissionStyles.diffWrapper,
|
||||
{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
@@ -1162,7 +1121,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.sectionTitle,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
File Content
|
||||
@@ -1173,7 +1132,7 @@ function PermissionRequestCard({
|
||||
style={[
|
||||
permissionStyles.contentCard,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
@@ -1183,7 +1142,7 @@ function PermissionRequestCard({
|
||||
style={[
|
||||
permissionStyles.metadataLabel,
|
||||
{
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
]}
|
||||
@@ -1209,7 +1168,7 @@ function PermissionRequestCard({
|
||||
<Text
|
||||
style={[
|
||||
permissionStyles.sectionTitle,
|
||||
{ color: theme.colors.mutedForeground },
|
||||
{ color: theme.colors.foregroundMuted },
|
||||
]}
|
||||
>
|
||||
Raw Request
|
||||
@@ -1218,7 +1177,7 @@ function PermissionRequestCard({
|
||||
style={[
|
||||
permissionStyles.contentCard,
|
||||
{
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
@@ -1306,7 +1265,7 @@ function PermissionRequestCard({
|
||||
const stylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentWrapper: {
|
||||
width: "100%",
|
||||
@@ -1361,14 +1320,14 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: theme.colors.mutedForeground,
|
||||
backgroundColor: theme.colors.foregroundMuted,
|
||||
},
|
||||
invertedWrapper: {
|
||||
transform: [{ scaleY: -1 }],
|
||||
width: "100%",
|
||||
},
|
||||
emptyStateText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
@@ -1390,7 +1349,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
shadowColor: "#000",
|
||||
|
||||
@@ -26,7 +26,7 @@ interface ArtifactDrawerProps {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: {
|
||||
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
closeButton: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
@@ -91,7 +91,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
contentScroll: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentScrollContainer: {
|
||||
padding: theme.spacing[4],
|
||||
@@ -103,16 +103,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
imagePlaceholderSubtext: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
codeContainer: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[4],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
@@ -124,14 +124,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
metadataContainer: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
padding: theme.spacing[3],
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
metadataTitle: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
marginBottom: theme.spacing[2],
|
||||
@@ -141,12 +141,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
metadataLabel: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
width: 80,
|
||||
},
|
||||
metadataValue: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flex: 1,
|
||||
fontFamily: Fonts.mono,
|
||||
|
||||
@@ -100,16 +100,16 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.colors.secondary,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.title, { color: theme.colors.mutedForeground }]}>{title}</Text>
|
||||
<Text style={[styles.title, { color: theme.colors.foregroundMuted }]}>{title}</Text>
|
||||
{onDismiss ? (
|
||||
<Pressable accessibilityLabel="Dismiss audio debug" onPress={onDismiss} hitSlop={8}>
|
||||
<X size={14} color={theme.colors.mutedForeground} />
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -131,13 +131,13 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }:
|
||||
</View>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text style={[styles.hint, { color: theme.colors.mutedForeground }]}>
|
||||
<Text style={[styles.hint, { color: theme.colors.foregroundMuted }]}>
|
||||
Raw audio path unavailable. Set STT_DEBUG_AUDIO_DIR on the server to persist recordings.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{stats ? (
|
||||
<Text style={[styles.stats, { color: theme.colors.mutedForeground }]}>
|
||||
<Text style={[styles.stats, { color: theme.colors.foregroundMuted }]}>
|
||||
{stats}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -108,7 +108,7 @@ export function useCommandAutocomplete(commands: AgentSlashCommand[], filter: st
|
||||
|
||||
const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -136,12 +136,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
commandArgs: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
commandDescription: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
@@ -150,7 +150,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
loadingText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
emptyItem: {
|
||||
@@ -158,7 +158,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
})) as any) as Record<string, any>;
|
||||
|
||||
@@ -317,7 +317,7 @@ export function ConversationSelector({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
triggerButton: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
@@ -329,7 +329,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.spacing[6],
|
||||
borderTopRightRadius: theme.spacing[6],
|
||||
height: "80%",
|
||||
@@ -360,7 +360,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
@@ -379,14 +379,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
},
|
||||
loadingText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: theme.spacing[8],
|
||||
alignItems: "center",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
conversationItem: {
|
||||
flexDirection: "row",
|
||||
@@ -398,7 +398,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
conversationItemActive: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
conversationContent: {
|
||||
flex: 1,
|
||||
@@ -409,7 +409,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
conversationDate: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -102,7 +102,7 @@ export function DictationControls({
|
||||
accessibilityLabel="Retry dictation"
|
||||
style={[styles.actionButton, styles.actionButtonConfirm]}
|
||||
>
|
||||
<RefreshCcw size={14} color={theme.colors.background} />
|
||||
<RefreshCcw size={14} color={theme.colors.surface0} />
|
||||
</Pressable>
|
||||
) : (
|
||||
<>
|
||||
@@ -118,7 +118,7 @@ export function DictationControls({
|
||||
accessibilityLabel="Insert transcription and send"
|
||||
style={[styles.actionButton, styles.actionButtonConfirm]}
|
||||
>
|
||||
<ArrowUp size={14} color={theme.colors.background} />
|
||||
<ArrowUp size={14} color={theme.colors.surface0} />
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
@@ -165,7 +165,7 @@ export function DictationOverlay({
|
||||
<View
|
||||
style={[
|
||||
overlayStyles.container,
|
||||
{ backgroundColor: theme.colors.palette.blue[600] },
|
||||
{ backgroundColor: theme.colors.accent },
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
@@ -215,7 +215,7 @@ export function DictationOverlay({
|
||||
>
|
||||
<RefreshCcw
|
||||
size={20}
|
||||
color={theme.colors.palette.blue[600]}
|
||||
color={theme.colors.accent}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</Pressable>
|
||||
@@ -243,7 +243,7 @@ export function DictationOverlay({
|
||||
>
|
||||
<ArrowUp
|
||||
size={20}
|
||||
color={theme.colors.palette.blue[600]}
|
||||
color={theme.colors.accent}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</Pressable>
|
||||
@@ -265,7 +265,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
activeContainer: {
|
||||
flexDirection: "row",
|
||||
@@ -296,11 +296,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
actionButtonCancel: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
actionButtonSecondary: {
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
actionButtonConfirm: {
|
||||
borderColor: theme.colors.foreground,
|
||||
|
||||
@@ -48,12 +48,12 @@ export function DictationStatusNotice({
|
||||
case "error":
|
||||
return theme.colors.palette.red[500];
|
||||
default:
|
||||
return theme.colors.background;
|
||||
return theme.colors.surface0;
|
||||
}
|
||||
})();
|
||||
|
||||
const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white;
|
||||
const secondaryColor = variant === "info" ? theme.colors.mutedForeground : theme.colors.palette.white;
|
||||
const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white;
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -108,22 +108,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
headerLine: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
headerText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
addLine: {
|
||||
backgroundColor: "rgba(46, 160, 67, 0.15)",
|
||||
},
|
||||
addText: {
|
||||
color: theme.colors.foreground,
|
||||
color: theme.colors.palette.green[800],
|
||||
},
|
||||
removeLine: {
|
||||
backgroundColor: "rgba(248, 81, 73, 0.1)",
|
||||
},
|
||||
removeText: {
|
||||
color: theme.colors.foreground,
|
||||
color: theme.colors.palette.red[800],
|
||||
},
|
||||
addHighlight: {
|
||||
backgroundColor: "rgba(46, 160, 67, 0.4)",
|
||||
@@ -132,10 +132,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "rgba(248, 81, 73, 0.35)",
|
||||
},
|
||||
contextLine: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
contextText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyState: {
|
||||
padding: theme.spacing[4],
|
||||
@@ -144,6 +144,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -49,7 +49,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
marginBottom: theme.spacing[8],
|
||||
},
|
||||
@@ -77,7 +77,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
secondaryButton: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
|
||||
@@ -251,7 +251,7 @@ function SidebarContent({
|
||||
color={
|
||||
activeTab === "changes"
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
@@ -272,7 +272,7 @@ function SidebarContent({
|
||||
color={
|
||||
activeTab === "files"
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground
|
||||
: theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
@@ -291,7 +291,7 @@ function SidebarContent({
|
||||
)}
|
||||
{isMobile && (
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
<X size={18} color={theme.colors.mutedForeground} />
|
||||
<X size={18} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -352,14 +352,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
desktopSidebar: {
|
||||
position: "relative",
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
resizeHandle: {
|
||||
position: "absolute",
|
||||
@@ -396,12 +396,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
tabTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -430,6 +430,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
viewToggleActive: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -80,7 +80,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
opacity: 0.7,
|
||||
},
|
||||
overlayContent: {
|
||||
|
||||
@@ -756,7 +756,7 @@ export function FileExplorerPane({
|
||||
</View>
|
||||
<Pressable style={styles.sortButton} onPress={handleSortCycle}>
|
||||
<Text style={styles.sortButtonText}>{currentSortLabel}</Text>
|
||||
<ChevronDown size={14} color={theme.colors.mutedForeground} />
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{showListLoadingBanner && (
|
||||
@@ -947,7 +947,7 @@ export function FileExplorerPane({
|
||||
{selectedEntryPath?.split("/").pop() ?? "Preview"}
|
||||
</Text>
|
||||
<Pressable onPress={handleClosePreviewSheet} style={styles.sheetCloseButton}>
|
||||
<X size={20} color={theme.colors.mutedForeground} />
|
||||
<X size={20} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{isPreviewLoading && !preview ? (
|
||||
@@ -1030,7 +1030,7 @@ export function FileExplorerPane({
|
||||
hitSlop={8}
|
||||
style={styles.downloadToastDismiss}
|
||||
>
|
||||
<X size={16} color={theme.colors.mutedForeground} />
|
||||
<X size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
@@ -1291,7 +1291,7 @@ function splitFileName(fileName: string): { base: string; ext: string } {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
@@ -1325,7 +1325,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pathText: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontFamily: Fonts.mono,
|
||||
},
|
||||
backButton: {
|
||||
@@ -1343,7 +1343,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
loadingBannerText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
sortButton: {
|
||||
@@ -1355,10 +1355,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
sortButtonText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
centerState: {
|
||||
@@ -1369,7 +1369,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
loadingText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
errorText: {
|
||||
@@ -1401,12 +1401,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
goToWorkspaceButtonText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
textAlign: "center",
|
||||
},
|
||||
@@ -1422,7 +1422,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
entryRowBackground: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
entryInfo: {
|
||||
flex: 1,
|
||||
@@ -1443,7 +1443,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
entryMeta: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
@@ -1465,7 +1465,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
padding: theme.spacing[1],
|
||||
},
|
||||
entryMenuItem: {
|
||||
@@ -1499,7 +1499,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
padding: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
@@ -1511,10 +1511,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
gridImageBackground: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
gridImage: {
|
||||
width: "100%",
|
||||
@@ -1525,11 +1525,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
gridMeta: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
sheetBackground: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
@@ -1586,7 +1586,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
@@ -1608,12 +1608,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
downloadToastStatus: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
downloadProgressBar: {
|
||||
height: 3,
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
marginTop: theme.spacing[1],
|
||||
overflow: "hidden",
|
||||
|
||||
@@ -37,41 +37,63 @@ interface HighlightedTextProps {
|
||||
lineType: "add" | "remove" | "context" | "header";
|
||||
}
|
||||
|
||||
// GitHub syntax highlight colors for dark/light modes
|
||||
const darkHighlightColors: Record<HighlightStyle, string> = {
|
||||
keyword: "#ff7b72",
|
||||
comment: "#8b949e",
|
||||
string: "#a5d6ff",
|
||||
number: "#79c0ff",
|
||||
literal: "#79c0ff",
|
||||
function: "#d2a8ff",
|
||||
definition: "#d2a8ff",
|
||||
class: "#ffa657",
|
||||
type: "#ff7b72",
|
||||
tag: "#7ee787",
|
||||
attribute: "#79c0ff",
|
||||
property: "#79c0ff",
|
||||
variable: "#c9d1d9",
|
||||
operator: "#79c0ff",
|
||||
punctuation: "#c9d1d9",
|
||||
regexp: "#a5d6ff",
|
||||
escape: "#79c0ff",
|
||||
meta: "#8b949e",
|
||||
heading: "#79c0ff",
|
||||
link: "#a5d6ff",
|
||||
};
|
||||
|
||||
const lightHighlightColors: Record<HighlightStyle, string> = {
|
||||
keyword: "#cf222e",
|
||||
comment: "#6e7781",
|
||||
string: "#0a3069",
|
||||
number: "#0550ae",
|
||||
literal: "#0550ae",
|
||||
function: "#8250df",
|
||||
definition: "#8250df",
|
||||
class: "#953800",
|
||||
type: "#cf222e",
|
||||
tag: "#116329",
|
||||
attribute: "#0550ae",
|
||||
property: "#0550ae",
|
||||
variable: "#24292f",
|
||||
operator: "#0550ae",
|
||||
punctuation: "#24292f",
|
||||
regexp: "#0a3069",
|
||||
escape: "#0550ae",
|
||||
meta: "#6e7781",
|
||||
heading: "#0550ae",
|
||||
link: "#0a3069",
|
||||
};
|
||||
|
||||
function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colors.surface0 === "#18181c";
|
||||
|
||||
// Get color for a highlight style using GitHub Dark theme
|
||||
// Text colors are the same regardless of line type - only background changes
|
||||
// Get color for a highlight style
|
||||
const getTokenColor = (style: HighlightStyle | null): string => {
|
||||
const baseColor = "#c9d1d9"; // GitHub foreground
|
||||
|
||||
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
||||
if (!style) return baseColor;
|
||||
|
||||
// GitHub Dark theme colors
|
||||
const highlightColors: Record<HighlightStyle, string> = {
|
||||
keyword: "#ff7b72", // red
|
||||
comment: "#8b949e", // gray
|
||||
string: "#a5d6ff", // light blue
|
||||
number: "#79c0ff", // blue
|
||||
literal: "#79c0ff", // blue
|
||||
function: "#d2a8ff", // purple
|
||||
definition: "#d2a8ff", // purple
|
||||
class: "#ffa657", // orange
|
||||
type: "#ff7b72", // red (same as keyword in GitHub)
|
||||
tag: "#7ee787", // green
|
||||
attribute: "#79c0ff", // blue
|
||||
property: "#79c0ff", // blue
|
||||
variable: baseColor,
|
||||
operator: "#79c0ff", // blue
|
||||
punctuation: "#c9d1d9", // foreground
|
||||
regexp: "#a5d6ff", // light blue
|
||||
escape: "#79c0ff", // blue
|
||||
meta: "#8b949e", // gray
|
||||
heading: "#79c0ff", // blue
|
||||
link: "#a5d6ff", // light blue
|
||||
};
|
||||
|
||||
return highlightColors[style] ?? baseColor;
|
||||
const colors = isDark ? darkHighlightColors : lightHighlightColors;
|
||||
return colors[style] ?? baseColor;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -253,7 +275,7 @@ const DiffFileSection = memo(function DiffFileSection({
|
||||
>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
color={theme.colors.mutedForeground}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.filePath} numberOfLines={1} ellipsizeMode="middle">
|
||||
@@ -470,7 +492,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
@@ -492,14 +514,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
fileSection: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.accentBorder,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
fileHeader: {
|
||||
@@ -566,13 +588,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffContent: {
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
backgroundColor: "#0d1117",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
diffContentInner: {
|
||||
flexDirection: "column",
|
||||
},
|
||||
linesContainer: {
|
||||
backgroundColor: "#0d1117",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
diffLineContainer: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -587,24 +609,24 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green
|
||||
},
|
||||
addLineText: {
|
||||
color: "#c9d1d9", // Same text color as all code
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
removeLineContainer: {
|
||||
backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red
|
||||
},
|
||||
removeLineText: {
|
||||
color: "#c9d1d9", // Same text color as all code
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
headerLineContainer: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
headerLineText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
contextLineContainer: {
|
||||
backgroundColor: "#0d1117",
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
contextLineText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -22,7 +22,7 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
onPress={onBack ?? (() => router.back())}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<ArrowLeft size={20} color={theme.colors.mutedForeground} />
|
||||
<ArrowLeft size={20} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{title && (
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
|
||||
@@ -19,7 +19,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
||||
const menuIconColor = !isMobile && isOpen
|
||||
? theme.colors.foreground
|
||||
: theme.colors.mutedForeground;
|
||||
: theme.colors.foregroundMuted;
|
||||
|
||||
return (
|
||||
<ScreenHeader
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeade
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
inner: {},
|
||||
row: {
|
||||
|
||||
@@ -212,7 +212,7 @@ export function HomeFooter() {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopWidth: theme.borderWidth[1],
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
@@ -264,7 +264,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
hostPickerContainer: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
padding: theme.spacing[4],
|
||||
borderTopLeftRadius: theme.borderRadius.xl,
|
||||
borderTopRightRadius: theme.borderRadius.xl,
|
||||
@@ -279,7 +279,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
hostPickerButton: {
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
hostPickerButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -290,7 +290,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[3],
|
||||
},
|
||||
hostPickerCancelText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
|
||||
@@ -616,9 +616,9 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
inputWrapper: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.accentBorder,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
paddingVertical: {
|
||||
xs: theme.spacing[2],
|
||||
@@ -645,7 +645,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
position: "relative",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.accentBorder,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
overflow: "hidden",
|
||||
...(IS_WEB
|
||||
? {
|
||||
@@ -726,7 +726,7 @@ const styles = StyleSheet.create(((theme: any) => ({
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.palette.blue[600],
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
|
||||
@@ -99,7 +99,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
bubble: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius.sm,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
@@ -120,13 +120,13 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
marginTop: theme.spacing[1],
|
||||
marginRight: theme.spacing[4],
|
||||
alignSelf: "flex-end",
|
||||
backgroundColor: theme.colors.secondary,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.base,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 4,
|
||||
},
|
||||
copiedTagText: {
|
||||
color: theme.colors.secondaryForeground,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
@@ -231,8 +231,8 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Used in custom markdownRules for inline code styling
|
||||
markdownCodeInline: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
@@ -241,7 +241,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
// Used in custom markdownRules for path chip styling
|
||||
pathChip: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: 2,
|
||||
@@ -249,7 +249,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
marginVertical: 2,
|
||||
},
|
||||
pathChipText: {
|
||||
color: theme.colors.secondaryForeground,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
fontSize: 13,
|
||||
},
|
||||
@@ -262,7 +262,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[4],
|
||||
},
|
||||
iconColor: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
iconHoveredColor: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -340,8 +340,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.accentBorder,
|
||||
backgroundColor: theme.colors.secondary,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
@@ -369,7 +369,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
secondaryLabel: {
|
||||
flex: 1,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
marginLeft: theme.spacing[2],
|
||||
@@ -386,7 +386,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 1,
|
||||
@@ -721,7 +721,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
detailsText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
@@ -875,7 +875,7 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
card: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
@@ -899,7 +899,7 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
timestamp: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
providerBadge: {
|
||||
@@ -914,7 +914,7 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
progressText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
@@ -946,11 +946,11 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
itemTextCompleted: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textDecorationLine: "line-through",
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
@@ -979,7 +979,7 @@ export const TodoListCard = memo(function TodoListCard({
|
||||
[timestamp]
|
||||
);
|
||||
|
||||
const iconColor = theme.colors.background;
|
||||
const iconColor = theme.colors.surface0;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -1174,7 +1174,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
{hasDetails ? (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
color={theme.colors.mutedForeground}
|
||||
color={theme.colors.foregroundMuted}
|
||||
style={[
|
||||
expandableBadgeStylesheet.chevron,
|
||||
{ transform: [{ rotate: isExpanded ? "90deg" : "0deg" }] },
|
||||
@@ -1194,7 +1194,7 @@ const ExpandableBadge = memo(function ExpandableBadge({
|
||||
|
||||
const agentThoughtStylesheet = StyleSheet.create((theme) => ({
|
||||
emptyText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontStyle: "italic" as const,
|
||||
},
|
||||
|
||||
@@ -75,7 +75,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
},
|
||||
modeSelectorContent: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
padding: theme.spacing[4],
|
||||
minWidth: 280,
|
||||
@@ -85,7 +85,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
modeItemActive: {
|
||||
backgroundColor: theme.colors.primary,
|
||||
@@ -100,7 +100,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.primaryForeground,
|
||||
},
|
||||
modeDescription: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
modeDescriptionActive: {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function RealtimeControls() {
|
||||
size={20}
|
||||
color={
|
||||
isMuted
|
||||
? theme.colors.background
|
||||
? theme.colors.surface0
|
||||
: theme.colors.foreground
|
||||
}
|
||||
/>
|
||||
@@ -91,7 +91,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: theme.borderWidth[2],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
|
||||
@@ -226,7 +226,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
style={styles.settingsButton}
|
||||
onPress={handleSettingsMobile}
|
||||
>
|
||||
<Settings size={20} color={theme.colors.mutedForeground} />
|
||||
<Settings size={20} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.settingsButtonText}>Settings</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -273,7 +273,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
style={styles.settingsButton}
|
||||
onPress={handleSettingsDesktop}
|
||||
>
|
||||
<Settings size={20} color={theme.colors.mutedForeground} />
|
||||
<Settings size={20} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.settingsButtonText}>Settings</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -294,7 +294,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
sidebarContent: {
|
||||
@@ -305,7 +305,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
desktopSidebar: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.background,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
sidebarHeader: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
@@ -326,7 +326,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
newAgentButtonHovered: {
|
||||
borderColor: theme.colors.palette.zinc[500],
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
newAgentButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
@@ -361,6 +361,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
settingsButtonText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -357,7 +357,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
textTransform: "uppercase",
|
||||
@@ -370,7 +370,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.base,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
fileBadgeText: {
|
||||
color: theme.colors.foreground,
|
||||
@@ -378,7 +378,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
rangeText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
diffContainer: {
|
||||
@@ -386,13 +386,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.base,
|
||||
overflow: "hidden",
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
scrollArea: {
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.base,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: theme.spacing[2],
|
||||
@@ -407,7 +407,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.base,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
jsonScrollError: {
|
||||
borderColor: theme.colors.destructive,
|
||||
@@ -419,7 +419,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
emptyStateText: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontStyle: "italic",
|
||||
},
|
||||
|
||||
@@ -7,11 +7,13 @@ import React, {
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated from "react-native-reanimated";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { Pencil, Eye, SquareTerminal, Search, Wrench, X } from "lucide-react-native";
|
||||
import { ToolCallDetailsContent, useToolCallDetails } from "./tool-call-details";
|
||||
@@ -53,6 +55,17 @@ const toolKindIcons: Record<string, React.ComponentType<{ size?: number; color?:
|
||||
search: Search,
|
||||
};
|
||||
|
||||
// ----- Custom Background Component -----
|
||||
|
||||
function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const containerStyle = useMemo(
|
||||
() => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }],
|
||||
[style, theme.colors.surface2]
|
||||
);
|
||||
return <Animated.View pointerEvents="none" style={containerStyle} />;
|
||||
}
|
||||
|
||||
// ----- Provider Component -----
|
||||
|
||||
interface ToolCallSheetProviderProps {
|
||||
@@ -163,14 +176,14 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sheetBackground: {
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
@@ -200,11 +213,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[2],
|
||||
},
|
||||
closeIcon: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.card,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
@@ -32,7 +32,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
elevation: 8,
|
||||
},
|
||||
buttonIdle: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
buttonRecording: {
|
||||
backgroundColor: theme.colors.destructive,
|
||||
@@ -44,7 +44,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.palette.green[500],
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
// Recording icon
|
||||
|
||||
@@ -35,13 +35,14 @@ import {
|
||||
type SessionState,
|
||||
type DaemonConnectionSnapshot,
|
||||
} from "@/stores/session-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
|
||||
// Re-export types from session-store for backward compatibility
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
export type {
|
||||
MessageEntry,
|
||||
DraftInput,
|
||||
ProviderModelState,
|
||||
Agent,
|
||||
ExplorerEntry,
|
||||
@@ -428,8 +429,7 @@ export function SessionProvider({
|
||||
const setGitDiffs = useSessionStore((state) => state.setGitDiffs);
|
||||
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
||||
const setProviderModels = useSessionStore((state) => state.setProviderModels);
|
||||
const getDraftInput = useSessionStore((state) => state.getDraftInput);
|
||||
const saveDraftInput = useSessionStore((state) => state.saveDraftInput);
|
||||
const clearDraftInput = useDraftStore((state) => state.clearDraftInput);
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
const getSession = useSessionStore((state) => state.getSession);
|
||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
||||
@@ -1515,7 +1515,7 @@ export function SessionProvider({
|
||||
clearAgentStreamHead(serverId, agentId);
|
||||
|
||||
// Remove draft input
|
||||
saveDraftInput(agentId, { text: "", images: [] });
|
||||
clearDraftInput(agentId);
|
||||
|
||||
setPendingPermissions(serverId, (prev) => {
|
||||
let changed = false;
|
||||
@@ -1592,7 +1592,7 @@ export function SessionProvider({
|
||||
setHasHydratedAgents,
|
||||
updateConnectionStatus,
|
||||
getSession,
|
||||
saveDraftInput,
|
||||
clearDraftInput,
|
||||
notifyAgentAttention,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
export interface DraftInput {
|
||||
text: string;
|
||||
images: Array<{ uri: string; mimeType: string }>;
|
||||
}
|
||||
|
||||
interface DraftStoreState {
|
||||
drafts: Record<string, DraftInput>;
|
||||
createModalDraft: DraftInput | null;
|
||||
}
|
||||
|
||||
interface DraftStoreActions {
|
||||
getDraftInput: (agentId: string) => DraftInput | undefined;
|
||||
saveDraftInput: (agentId: string, draft: DraftInput) => void;
|
||||
clearDraftInput: (agentId: string) => void;
|
||||
getCreateModalDraft: () => DraftInput | null;
|
||||
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreActions;
|
||||
|
||||
export const useDraftStore = create<DraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
|
||||
getDraftInput: (agentId) => {
|
||||
return get().drafts[agentId];
|
||||
},
|
||||
|
||||
saveDraftInput: (agentId, draft) => {
|
||||
set((state) => ({
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[agentId]: draft,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
clearDraftInput: (agentId) => {
|
||||
set((state) => {
|
||||
const { [agentId]: _, ...rest } = state.drafts;
|
||||
return { drafts: rest };
|
||||
});
|
||||
},
|
||||
|
||||
getCreateModalDraft: () => {
|
||||
return get().createModalDraft;
|
||||
},
|
||||
|
||||
saveCreateModalDraft: (draft) => {
|
||||
set({ createModalDraft: draft });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "paseo-drafts",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -60,11 +60,6 @@ export type MessageEntry =
|
||||
status: "executing" | "completed" | "failed";
|
||||
};
|
||||
|
||||
export type DraftInput = {
|
||||
text: string;
|
||||
images: Array<{ uri: string; mimeType: string }>;
|
||||
};
|
||||
|
||||
export type ProviderModelState = {
|
||||
models: AgentModelDefinition[] | null;
|
||||
fetchedAt: Date | null;
|
||||
@@ -250,12 +245,6 @@ export interface SessionState {
|
||||
interface SessionStoreState {
|
||||
sessions: Record<string, SessionState>;
|
||||
|
||||
// Top-level drafts (keyed by agentId, not serverId:agentId since agent IDs are globally unique)
|
||||
drafts: Map<string, DraftInput>;
|
||||
|
||||
// Single draft for the create agent modal
|
||||
createModalDraft: DraftInput | null;
|
||||
|
||||
// Agent activity timestamps (top-level, keyed by agentId to prevent cascade rerenders)
|
||||
agentLastActivity: Map<string, Date>;
|
||||
}
|
||||
@@ -305,14 +294,6 @@ interface SessionStoreActions {
|
||||
// Provider models
|
||||
setProviderModels: (serverId: string, models: Map<AgentProvider, ProviderModelState> | ((prev: Map<AgentProvider, ProviderModelState>) => Map<AgentProvider, ProviderModelState>)) => void;
|
||||
|
||||
// Draft inputs (top-level, not in sessions)
|
||||
getDraftInput: (agentId: string) => DraftInput | undefined;
|
||||
saveDraftInput: (agentId: string, draft: DraftInput) => void;
|
||||
|
||||
// Create modal draft
|
||||
getCreateModalDraft: () => DraftInput | null;
|
||||
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
||||
|
||||
// Queued messages
|
||||
setQueuedMessages: (serverId: string, value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void;
|
||||
|
||||
@@ -392,8 +373,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud
|
||||
export const useSessionStore = create<SessionStore>()(
|
||||
subscribeWithSelector((set, get) => ({
|
||||
sessions: {},
|
||||
drafts: new Map(),
|
||||
createModalDraft: null,
|
||||
agentLastActivity: new Map(),
|
||||
|
||||
// Session management
|
||||
@@ -779,34 +758,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
// Draft inputs (top-level, keyed by agentId only)
|
||||
getDraftInput: (agentId) => {
|
||||
return get().drafts.get(agentId);
|
||||
},
|
||||
|
||||
saveDraftInput: (agentId, draft) => {
|
||||
set((prev) => {
|
||||
const nextDrafts = new Map(prev.drafts);
|
||||
nextDrafts.set(agentId, draft);
|
||||
return {
|
||||
...prev,
|
||||
drafts: nextDrafts,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Create modal draft
|
||||
getCreateModalDraft: () => {
|
||||
return get().createModalDraft;
|
||||
},
|
||||
|
||||
saveCreateModalDraft: (draft) => {
|
||||
set((prev) => ({
|
||||
...prev,
|
||||
createModalDraft: draft,
|
||||
}));
|
||||
},
|
||||
|
||||
// Queued messages
|
||||
setQueuedMessages: (serverId, value) => {
|
||||
set((prev) => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
heading6: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginTop: theme.spacing[3],
|
||||
marginBottom: theme.spacing[1],
|
||||
lineHeight: 20,
|
||||
@@ -104,7 +104,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
strong: {
|
||||
fontWeight: theme.fontWeight.bold,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
|
||||
em: {
|
||||
@@ -113,7 +113,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
|
||||
s: {
|
||||
textDecorationLine: "line-through" as const,
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
link: {
|
||||
@@ -131,8 +131,8 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
code_inline: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
@@ -141,8 +141,8 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
code_block: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
fontFamily: Fonts.mono,
|
||||
@@ -151,8 +151,8 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
fence: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
color: theme.colors.secondaryForeground,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
color: theme.colors.foreground,
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
@@ -178,7 +178,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
thead: {
|
||||
backgroundColor: theme.colors.secondary,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
tbody: {},
|
||||
@@ -188,7 +188,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
borderBottomWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.secondary,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -244,14 +244,14 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
},
|
||||
|
||||
bullet_list_icon: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
|
||||
ordered_list_icon: {
|
||||
color: theme.colors.mutedForeground,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginRight: 4,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
@@ -264,7 +264,7 @@ export function createMarkdownStyles(theme: Theme) {
|
||||
// =========================================================================
|
||||
|
||||
blockquote: {
|
||||
backgroundColor: theme.colors.muted,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: theme.colors.primary,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
@@ -103,10 +103,34 @@ export const baseColors = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
// Semantic color tokens (shadcn-style)
|
||||
// Semantic color tokens - Layer-based system
|
||||
const lightSemanticColors = {
|
||||
background: "#ffffff",
|
||||
// Surfaces (layers) - shifted one step lighter
|
||||
surface0: "#ffffff", // App background
|
||||
surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50)
|
||||
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
|
||||
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
|
||||
|
||||
// Text
|
||||
foreground: "#09090b",
|
||||
foregroundMuted: "#71717a",
|
||||
|
||||
// Borders - shifted one step lighter
|
||||
border: "#e4e4e7", // (was zinc-200, now zinc-200 - keep for contrast)
|
||||
borderAccent: "#e4e4e7", // (was zinc-300, now zinc-200)
|
||||
|
||||
// Brand
|
||||
accent: "#20744A",
|
||||
accentForeground: "#ffffff",
|
||||
|
||||
// Semantic
|
||||
destructive: "#dc2626",
|
||||
destructiveForeground: "#ffffff",
|
||||
success: "#20744A",
|
||||
successForeground: "#ffffff",
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#ffffff",
|
||||
card: "#ffffff",
|
||||
cardForeground: "#09090b",
|
||||
popover: "#ffffff",
|
||||
@@ -114,25 +138,44 @@ const lightSemanticColors = {
|
||||
primary: "#18181b",
|
||||
primaryForeground: "#fafafa",
|
||||
secondary: "#f4f4f5",
|
||||
secondaryForeground: "#18181b",
|
||||
secondaryForeground: "#09090b",
|
||||
muted: "#f4f4f5",
|
||||
mutedForeground: "#71717a",
|
||||
accent: "#f4f4f5",
|
||||
accentForeground: "#18181b",
|
||||
destructive: "#ef4444",
|
||||
destructiveForeground: "#fafafa",
|
||||
border: "#e4e4e7",
|
||||
accentBorder: "#3f3f46",
|
||||
input: "#e4e4e7",
|
||||
accentBorder: "#e4e4e7",
|
||||
input: "#f4f4f5",
|
||||
ring: "#18181b",
|
||||
} as const;
|
||||
|
||||
const darkSemanticColors = {
|
||||
background: "#09090b",
|
||||
// Surfaces (layers)
|
||||
surface0: "#18181c", // App background
|
||||
surface1: "#1f1f23", // Subtle hover
|
||||
surface2: "#27272a", // Elevated: badges, inputs, sheets
|
||||
surface3: "#3f3f46", // Highest elevation
|
||||
|
||||
// Text
|
||||
foreground: "#fafafa",
|
||||
card: "#09090b",
|
||||
foregroundMuted: "#a1a1aa",
|
||||
|
||||
// Borders
|
||||
border: "#27272a",
|
||||
borderAccent: "#3f3f46",
|
||||
|
||||
// Brand
|
||||
accent: "#20744A",
|
||||
accentForeground: "#ffffff",
|
||||
|
||||
// Semantic
|
||||
destructive: "#ef4444",
|
||||
destructiveForeground: "#ffffff",
|
||||
success: "#20744A",
|
||||
successForeground: "#ffffff",
|
||||
|
||||
// Legacy aliases (for gradual migration)
|
||||
background: "#18181c",
|
||||
card: "#27272a",
|
||||
cardForeground: "#fafafa",
|
||||
popover: "#18181b",
|
||||
popover: "#27272a",
|
||||
popoverForeground: "#fafafa",
|
||||
primary: "#fafafa",
|
||||
primaryForeground: "#18181b",
|
||||
@@ -140,12 +183,7 @@ const darkSemanticColors = {
|
||||
secondaryForeground: "#fafafa",
|
||||
muted: "#27272a",
|
||||
mutedForeground: "#a1a1aa",
|
||||
accent: "#27272a",
|
||||
accentForeground: "#fafafa",
|
||||
destructive: "#7f1d1d",
|
||||
destructiveForeground: "#fafafa",
|
||||
border: "#27272e",
|
||||
accentBorder: "#414146",
|
||||
accentBorder: "#3f3f46",
|
||||
input: "#27272a",
|
||||
ring: "#d4d4d8",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@paseo/relay",
|
||||
"version": "0.1.0",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./node": "./src/node-adapter.ts",
|
||||
"./cloudflare": "./src/cloudflare-adapter.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241230.0",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/ws": "^8.5.8",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Cloudflare Durable Objects adapter for the relay.
|
||||
*
|
||||
* This module provides a Durable Object class that can be deployed to
|
||||
* Cloudflare Workers. It uses WebSocket hibernation for cost efficiency.
|
||||
*
|
||||
* Each session gets its own Durable Object instance, identified by session ID.
|
||||
*
|
||||
* Wrangler config:
|
||||
* ```jsonc
|
||||
* {
|
||||
* "durable_objects": {
|
||||
* "bindings": [{ "name": "RELAY", "class_name": "RelayDurableObject" }]
|
||||
* },
|
||||
* "migrations": [{ "tag": "v1", "new_classes": ["RelayDurableObject"] }]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { ConnectionRole, RelaySessionAttachment } from "./types.js";
|
||||
|
||||
type WebSocketPair = {
|
||||
0: WebSocket;
|
||||
1: WebSocket;
|
||||
};
|
||||
|
||||
interface DurableObjectState {
|
||||
acceptWebSocket(ws: WebSocket, tags?: string[]): void;
|
||||
getWebSockets(tag?: string): WebSocket[];
|
||||
}
|
||||
|
||||
interface WebSocketWithAttachment extends WebSocket {
|
||||
serializeAttachment(value: unknown): void;
|
||||
deserializeAttachment(): unknown;
|
||||
}
|
||||
|
||||
interface Env {
|
||||
RELAY: DurableObjectNamespace;
|
||||
}
|
||||
|
||||
interface DurableObjectNamespace {
|
||||
idFromName(name: string): DurableObjectId;
|
||||
get(id: DurableObjectId): DurableObjectStub;
|
||||
}
|
||||
|
||||
interface DurableObjectId {
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
interface DurableObjectStub {
|
||||
fetch(request: Request): Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable Object that handles WebSocket relay for a single session.
|
||||
*
|
||||
* Two WebSockets connect to this DO:
|
||||
* - role=server: The Paseo daemon
|
||||
* - role=client: The mobile/web app
|
||||
*
|
||||
* Messages are forwarded between them. The DO hibernates when idle.
|
||||
*/
|
||||
interface CFResponseInit extends ResponseInit {
|
||||
webSocket?: WebSocket;
|
||||
}
|
||||
|
||||
export class RelayDurableObject {
|
||||
private state: DurableObjectState;
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.state = state;
|
||||
|
||||
}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const role = url.searchParams.get("role") as ConnectionRole | null;
|
||||
const sessionId = url.searchParams.get("session");
|
||||
|
||||
if (!role || (role !== "server" && role !== "client")) {
|
||||
return new Response("Missing or invalid role parameter", { status: 400 });
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return new Response("Missing session parameter", { status: 400 });
|
||||
}
|
||||
|
||||
const upgradeHeader = request.headers.get("Upgrade");
|
||||
if (!upgradeHeader || upgradeHeader.toLowerCase() !== "websocket") {
|
||||
return new Response("Expected WebSocket upgrade", { status: 426 });
|
||||
}
|
||||
|
||||
// Close any existing connection with the same role
|
||||
const existingConnections = this.state.getWebSockets(role);
|
||||
for (const ws of existingConnections) {
|
||||
ws.close(1008, "Replaced by new connection");
|
||||
}
|
||||
|
||||
// Create WebSocket pair
|
||||
const pair = new (globalThis as unknown as { WebSocketPair: new () => WebSocketPair }).WebSocketPair();
|
||||
const [client, server] = [pair[0], pair[1]];
|
||||
|
||||
// Accept with hibernation support, tagged by role
|
||||
this.state.acceptWebSocket(server, [role]);
|
||||
|
||||
// Store attachment for hibernation recovery
|
||||
const attachment: RelaySessionAttachment = {
|
||||
sessionId,
|
||||
role,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
(server as WebSocketWithAttachment).serializeAttachment(attachment);
|
||||
|
||||
console.log(`[Relay DO] ${role} connected to session ${sessionId}`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
} as CFResponseInit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a WebSocket message is received (wakes from hibernation).
|
||||
*/
|
||||
webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
if (!attachment) {
|
||||
console.error("[Relay DO] Message from WebSocket without attachment");
|
||||
return;
|
||||
}
|
||||
|
||||
const { role } = attachment;
|
||||
const targetRole = role === "server" ? "client" : "server";
|
||||
const targets = this.state.getWebSockets(targetRole);
|
||||
|
||||
for (const target of targets) {
|
||||
try {
|
||||
target.send(message);
|
||||
} catch (error) {
|
||||
console.error(`[Relay DO] Failed to forward to ${targetRole}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a WebSocket closes (wakes from hibernation).
|
||||
*/
|
||||
webSocketClose(
|
||||
ws: WebSocket,
|
||||
code: number,
|
||||
reason: string,
|
||||
_wasClean: boolean
|
||||
): void {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
if (!attachment) return;
|
||||
|
||||
console.log(
|
||||
`[Relay DO] ${attachment.role} disconnected from session ${attachment.sessionId} (${code}: ${reason})`
|
||||
);
|
||||
|
||||
// Note: The other connection remains open.
|
||||
// The client/server will detect the peer is gone when they try to send.
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on WebSocket error.
|
||||
*/
|
||||
webSocketError(ws: WebSocket, error: unknown): void {
|
||||
const attachment = (ws as WebSocketWithAttachment).deserializeAttachment() as RelaySessionAttachment | null;
|
||||
console.error(
|
||||
`[Relay DO] WebSocket error for ${attachment?.role ?? "unknown"}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker entry point that routes requests to the appropriate Durable Object.
|
||||
*/
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Health check
|
||||
if (url.pathname === "/health") {
|
||||
return new Response(JSON.stringify({ status: "ok" }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Relay endpoint
|
||||
if (url.pathname === "/ws") {
|
||||
const sessionId = url.searchParams.get("session");
|
||||
if (!sessionId) {
|
||||
return new Response("Missing session parameter", { status: 400 });
|
||||
}
|
||||
|
||||
// Route to Durable Object instance for this session
|
||||
const id = env.RELAY.idFromName(sessionId);
|
||||
const stub = env.RELAY.get(id);
|
||||
return stub.fetch(request);
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
describe("crypto", () => {
|
||||
describe("generateKeyPair", () => {
|
||||
it("generates a valid ECDH keypair", async () => {
|
||||
const keypair = await generateKeyPair();
|
||||
expect(keypair.privateKey).toBeDefined();
|
||||
expect(keypair.publicKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportPublicKey / importPublicKey", () => {
|
||||
it("roundtrips public key through base64", async () => {
|
||||
const keypair = await generateKeyPair();
|
||||
const exported = await exportPublicKey(keypair.publicKey);
|
||||
|
||||
expect(typeof exported).toBe("string");
|
||||
expect(exported.length).toBeGreaterThan(0);
|
||||
|
||||
const imported = await importPublicKey(exported);
|
||||
expect(imported).toBeDefined();
|
||||
|
||||
// Re-export should match
|
||||
const reExported = await exportPublicKey(imported);
|
||||
expect(reExported).toBe(exported);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveSharedKey", () => {
|
||||
it("derives the same key on both sides", async () => {
|
||||
// Simulate daemon and client
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
|
||||
// Export public keys (what would go over the wire)
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
// Import peer's public key
|
||||
const daemonSeesClientPubKey = await importPublicKey(clientPubKeyB64);
|
||||
const clientSeesDaemonPubKey = await importPublicKey(daemonPubKeyB64);
|
||||
|
||||
// Derive shared keys
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
daemonSeesClientPubKey
|
||||
);
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
clientSeesDaemonPubKey
|
||||
);
|
||||
|
||||
// Both should derive the same key - test by encrypting with one, decrypting with other
|
||||
const testMessage = "Hello, encrypted world!";
|
||||
const encrypted = await encrypt(daemonSharedKey, testMessage);
|
||||
const decrypted = await decrypt(clientSharedKey, encrypted);
|
||||
|
||||
expect(decrypted).toBe(testMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt / decrypt", () => {
|
||||
it("roundtrips a string message", async () => {
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const sharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientKeyPair.publicKey
|
||||
);
|
||||
|
||||
const plaintext = "Test message with unicode: 你好世界 🎉";
|
||||
const ciphertext = await encrypt(sharedKey, plaintext);
|
||||
|
||||
expect(ciphertext).toBeInstanceOf(ArrayBuffer);
|
||||
expect(ciphertext.byteLength).toBeGreaterThan(plaintext.length);
|
||||
|
||||
const decrypted = await decrypt(sharedKey, ciphertext);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("roundtrips binary data", async () => {
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const sharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientKeyPair.publicKey
|
||||
);
|
||||
|
||||
const binary = new Uint8Array([0, 1, 2, 255, 254, 253]);
|
||||
const ciphertext = await encrypt(sharedKey, binary.buffer);
|
||||
|
||||
const decrypted = await decrypt(sharedKey, ciphertext);
|
||||
expect(new Uint8Array(decrypted as ArrayBuffer)).toEqual(binary);
|
||||
});
|
||||
|
||||
it("fails to decrypt with wrong key", async () => {
|
||||
const keypair1 = await generateKeyPair();
|
||||
const keypair2 = await generateKeyPair();
|
||||
const keypair3 = await generateKeyPair();
|
||||
|
||||
const correctKey = await deriveSharedKey(
|
||||
keypair1.privateKey,
|
||||
keypair2.publicKey
|
||||
);
|
||||
const wrongKey = await deriveSharedKey(
|
||||
keypair1.privateKey,
|
||||
keypair3.publicKey
|
||||
);
|
||||
|
||||
const ciphertext = await encrypt(correctKey, "secret");
|
||||
|
||||
await expect(decrypt(wrongKey, ciphertext)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("produces different ciphertext for same plaintext (random IV)", async () => {
|
||||
const keypair1 = await generateKeyPair();
|
||||
const keypair2 = await generateKeyPair();
|
||||
const sharedKey = await deriveSharedKey(
|
||||
keypair1.privateKey,
|
||||
keypair2.publicKey
|
||||
);
|
||||
|
||||
const plaintext = "Same message";
|
||||
const ciphertext1 = await encrypt(sharedKey, plaintext);
|
||||
const ciphertext2 = await encrypt(sharedKey, plaintext);
|
||||
|
||||
// Should be different due to random IV
|
||||
const arr1 = new Uint8Array(ciphertext1);
|
||||
const arr2 = new Uint8Array(ciphertext2);
|
||||
expect(arr1).not.toEqual(arr2);
|
||||
|
||||
// But both should decrypt to same plaintext
|
||||
expect(await decrypt(sharedKey, ciphertext1)).toBe(plaintext);
|
||||
expect(await decrypt(sharedKey, ciphertext2)).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full handshake simulation", () => {
|
||||
it("simulates complete daemon<->client key exchange", async () => {
|
||||
// === DAEMON SIDE (generates session) ===
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
// QR code would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
|
||||
|
||||
// === CLIENT SIDE (scans QR) ===
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
// Client imports daemon's public key from QR
|
||||
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
|
||||
|
||||
// Client derives shared key
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
daemonPubKeyOnClient
|
||||
);
|
||||
|
||||
// Client sends hello: { type: "hello", key: clientPubKeyB64 }
|
||||
|
||||
// === DAEMON SIDE (receives hello) ===
|
||||
// Daemon imports client's public key from hello message
|
||||
const clientPubKeyOnDaemon = await importPublicKey(clientPubKeyB64);
|
||||
|
||||
// Daemon derives shared key
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKeyOnDaemon
|
||||
);
|
||||
|
||||
// === VERIFY BOTH HAVE SAME KEY ===
|
||||
const testFromDaemon = "Message from daemon";
|
||||
const testFromClient = "Message from client";
|
||||
|
||||
// Daemon encrypts, client decrypts
|
||||
const encryptedFromDaemon = await encrypt(daemonSharedKey, testFromDaemon);
|
||||
expect(await decrypt(clientSharedKey, encryptedFromDaemon)).toBe(
|
||||
testFromDaemon
|
||||
);
|
||||
|
||||
// Client encrypts, daemon decrypts
|
||||
const encryptedFromClient = await encrypt(clientSharedKey, testFromClient);
|
||||
expect(await decrypt(daemonSharedKey, encryptedFromClient)).toBe(
|
||||
testFromClient
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* E2EE crypto primitives using WebCrypto API.
|
||||
*
|
||||
* - ECDH P-256 for key exchange
|
||||
* - HKDF for key derivation
|
||||
* - AES-256-GCM for authenticated encryption
|
||||
*/
|
||||
|
||||
const ECDH_ALGORITHM = { name: "ECDH", namedCurve: "P-256" };
|
||||
const AES_ALGORITHM = { name: "AES-GCM", length: 256 };
|
||||
const IV_LENGTH = 12;
|
||||
|
||||
/**
|
||||
* Generate an ECDH P-256 keypair for key exchange.
|
||||
*/
|
||||
export async function generateKeyPair(): Promise<CryptoKeyPair> {
|
||||
return crypto.subtle.generateKey(ECDH_ALGORITHM, true, ["deriveBits"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a public key to base64 string (for transmission).
|
||||
*/
|
||||
export async function exportPublicKey(key: CryptoKey): Promise<string> {
|
||||
const raw = await crypto.subtle.exportKey("raw", key);
|
||||
return arrayBufferToBase64(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a public key from base64 string.
|
||||
*/
|
||||
export async function importPublicKey(base64: string): Promise<CryptoKey> {
|
||||
const raw = base64ToArrayBuffer(base64);
|
||||
return crypto.subtle.importKey("raw", raw, ECDH_ALGORITHM, true, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a shared AES-256-GCM key from ECDH key exchange.
|
||||
*
|
||||
* Uses HKDF with SHA-256 to derive the final key.
|
||||
*/
|
||||
export async function deriveSharedKey(
|
||||
privateKey: CryptoKey,
|
||||
peerPublicKey: CryptoKey
|
||||
): Promise<CryptoKey> {
|
||||
// Perform ECDH to get shared secret bits
|
||||
const sharedBits = await crypto.subtle.deriveBits(
|
||||
{ name: "ECDH", public: peerPublicKey },
|
||||
privateKey,
|
||||
256
|
||||
);
|
||||
|
||||
// Import shared bits as HKDF key material
|
||||
const hkdfKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedBits,
|
||||
"HKDF",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
|
||||
// Derive AES-256-GCM key using HKDF
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "HKDF",
|
||||
hash: "SHA-256",
|
||||
salt: new Uint8Array(0),
|
||||
info: new TextEncoder().encode("paseo-e2ee-v1"),
|
||||
},
|
||||
hkdfKey,
|
||||
AES_ALGORITHM,
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data with AES-256-GCM.
|
||||
*
|
||||
* Returns: [IV (12 bytes)][ciphertext + auth tag]
|
||||
*/
|
||||
export async function encrypt(
|
||||
key: CryptoKey,
|
||||
data: string | ArrayBuffer
|
||||
): Promise<ArrayBuffer> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const plaintext =
|
||||
typeof data === "string" ? new TextEncoder().encode(data) : data;
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
plaintext
|
||||
);
|
||||
|
||||
// Prepend IV to ciphertext
|
||||
const result = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
|
||||
result.set(iv, 0);
|
||||
result.set(new Uint8Array(ciphertext), IV_LENGTH);
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data with AES-256-GCM.
|
||||
*
|
||||
* Input format: [IV (12 bytes)][ciphertext + auth tag]
|
||||
* Returns string if original was string, ArrayBuffer if binary.
|
||||
*/
|
||||
export async function decrypt(
|
||||
key: CryptoKey,
|
||||
data: ArrayBuffer
|
||||
): Promise<string | ArrayBuffer> {
|
||||
const dataArray = new Uint8Array(data);
|
||||
const iv = dataArray.slice(0, IV_LENGTH);
|
||||
const ciphertext = dataArray.slice(IV_LENGTH);
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
// Try to decode as UTF-8 string, fall back to ArrayBuffer
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
|
||||
} catch {
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { createRelayServer, RelayServer } from "./node-adapter.js";
|
||||
import {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
const TEST_PORT = 19999;
|
||||
|
||||
describe("E2E Relay with E2EE", () => {
|
||||
let relay: RelayServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
relay = createRelayServer({ port: TEST_PORT, host: "127.0.0.1" });
|
||||
await relay.start();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await relay.stop();
|
||||
});
|
||||
|
||||
it("full flow: daemon and client exchange encrypted messages through relay", async () => {
|
||||
const sessionId = "test-session-" + Date.now();
|
||||
|
||||
// === DAEMON SIDE ===
|
||||
// Generate keypair (public key goes in QR)
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
// QR would contain: { s: sessionId, k: daemonPubKeyB64, c: [...urls] }
|
||||
|
||||
// Daemon connects to relay as "server" role
|
||||
const daemonWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
daemonWs.on("open", resolve);
|
||||
daemonWs.on("error", reject);
|
||||
});
|
||||
|
||||
// === CLIENT SIDE ===
|
||||
// Client scans QR, gets daemon's public key and session ID
|
||||
// Client generates own keypair
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
// Client imports daemon's public key and derives shared secret
|
||||
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
daemonPubKeyOnClient
|
||||
);
|
||||
|
||||
// Client connects to relay as "client" role
|
||||
const clientWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
clientWs.on("open", resolve);
|
||||
clientWs.on("error", reject);
|
||||
});
|
||||
|
||||
// Client sends hello with its public key (this message is NOT encrypted - it's the handshake)
|
||||
const helloMsg = JSON.stringify({ type: "hello", key: clientPubKeyB64 });
|
||||
clientWs.send(helloMsg);
|
||||
|
||||
// === DAEMON RECEIVES HELLO ===
|
||||
const daemonReceivedHello = await new Promise<string>((resolve) => {
|
||||
daemonWs.once("message", (data) => resolve(data.toString()));
|
||||
});
|
||||
|
||||
const hello = JSON.parse(daemonReceivedHello);
|
||||
expect(hello.type).toBe("hello");
|
||||
expect(hello.key).toBe(clientPubKeyB64);
|
||||
|
||||
// Daemon imports client's public key and derives shared secret
|
||||
const clientPubKeyOnDaemon = await importPublicKey(hello.key);
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKeyOnDaemon
|
||||
);
|
||||
|
||||
// === VERIFY BOTH HAVE SAME KEY - Exchange encrypted messages ===
|
||||
|
||||
// Daemon sends encrypted "ready" message
|
||||
const readyPlaintext = JSON.stringify({ type: "ready" });
|
||||
const readyCiphertext = await encrypt(daemonSharedKey, readyPlaintext);
|
||||
daemonWs.send(Buffer.from(readyCiphertext));
|
||||
|
||||
// Client receives and decrypts
|
||||
const clientReceivedReady = await new Promise<Buffer>((resolve) => {
|
||||
clientWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
const decryptedReady = await decrypt(
|
||||
clientSharedKey,
|
||||
clientReceivedReady.buffer.slice(
|
||||
clientReceivedReady.byteOffset,
|
||||
clientReceivedReady.byteOffset + clientReceivedReady.byteLength
|
||||
)
|
||||
);
|
||||
expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" });
|
||||
|
||||
// Client sends encrypted message
|
||||
const clientMessage = "Hello from client!";
|
||||
const clientCiphertext = await encrypt(clientSharedKey, clientMessage);
|
||||
clientWs.send(Buffer.from(clientCiphertext));
|
||||
|
||||
// Daemon receives and decrypts
|
||||
const daemonReceivedMsg = await new Promise<Buffer>((resolve) => {
|
||||
daemonWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
const decryptedClientMsg = await decrypt(
|
||||
daemonSharedKey,
|
||||
daemonReceivedMsg.buffer.slice(
|
||||
daemonReceivedMsg.byteOffset,
|
||||
daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength
|
||||
)
|
||||
);
|
||||
expect(decryptedClientMsg).toBe(clientMessage);
|
||||
|
||||
// Daemon sends encrypted response
|
||||
const daemonMessage = "Hello from daemon!";
|
||||
const daemonCiphertext = await encrypt(daemonSharedKey, daemonMessage);
|
||||
daemonWs.send(Buffer.from(daemonCiphertext));
|
||||
|
||||
// Client receives and decrypts
|
||||
const clientReceivedMsg = await new Promise<Buffer>((resolve) => {
|
||||
clientWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
const decryptedDaemonMsg = await decrypt(
|
||||
clientSharedKey,
|
||||
clientReceivedMsg.buffer.slice(
|
||||
clientReceivedMsg.byteOffset,
|
||||
clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength
|
||||
)
|
||||
);
|
||||
expect(decryptedDaemonMsg).toBe(daemonMessage);
|
||||
|
||||
// Cleanup
|
||||
daemonWs.close();
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("relay only sees opaque bytes after handshake", async () => {
|
||||
const sessionId = "opaque-test-" + Date.now();
|
||||
|
||||
// Setup keys
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
||||
|
||||
const clientPubKey = await importPublicKey(clientPubKeyB64);
|
||||
const daemonPubKey = await importPublicKey(daemonPubKeyB64);
|
||||
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKey
|
||||
);
|
||||
const clientSharedKey = await deriveSharedKey(
|
||||
clientKeyPair.privateKey,
|
||||
daemonPubKey
|
||||
);
|
||||
|
||||
// Connect both
|
||||
const daemonWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=server`
|
||||
);
|
||||
const clientWs = new WebSocket(
|
||||
`ws://127.0.0.1:${TEST_PORT}/ws?session=${sessionId}&role=client`
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
new Promise<void>((r) => daemonWs.on("open", r)),
|
||||
new Promise<void>((r) => clientWs.on("open", r)),
|
||||
]);
|
||||
|
||||
// Send encrypted secret
|
||||
const secret = "This is a secret that relay cannot read";
|
||||
const ciphertext = await encrypt(clientSharedKey, secret);
|
||||
clientWs.send(Buffer.from(ciphertext));
|
||||
|
||||
// Daemon receives
|
||||
const received = await new Promise<Buffer>((resolve) => {
|
||||
daemonWs.once("message", (data) => resolve(data as Buffer));
|
||||
});
|
||||
|
||||
// The raw bytes don't contain the plaintext
|
||||
const rawString = received.toString("utf-8");
|
||||
expect(rawString).not.toContain(secret);
|
||||
|
||||
// But daemon can decrypt
|
||||
const decrypted = await decrypt(
|
||||
daemonSharedKey,
|
||||
received.buffer.slice(
|
||||
received.byteOffset,
|
||||
received.byteOffset + received.byteLength
|
||||
)
|
||||
);
|
||||
expect(decrypted).toBe(secret);
|
||||
|
||||
daemonWs.close();
|
||||
clientWs.close();
|
||||
});
|
||||
|
||||
it("wrong key cannot decrypt", async () => {
|
||||
const sessionId = "wrong-key-test-" + Date.now();
|
||||
|
||||
// Setup - daemon and client with correct keys
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const clientKeyPair = await generateKeyPair();
|
||||
const attackerKeyPair = await generateKeyPair();
|
||||
|
||||
const clientPubKey = await importPublicKey(
|
||||
await exportPublicKey(clientKeyPair.publicKey)
|
||||
);
|
||||
const daemonSharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPubKey
|
||||
);
|
||||
|
||||
// Attacker tries to derive key with their own keypair
|
||||
const attackerPubKey = await importPublicKey(
|
||||
await exportPublicKey(attackerKeyPair.publicKey)
|
||||
);
|
||||
const attackerKey = await deriveSharedKey(
|
||||
attackerKeyPair.privateKey,
|
||||
attackerPubKey
|
||||
);
|
||||
|
||||
// Encrypt with daemon's key
|
||||
const secret = "Top secret message";
|
||||
const ciphertext = await encrypt(daemonSharedKey, secret);
|
||||
|
||||
// Attacker cannot decrypt
|
||||
await expect(decrypt(attackerKey, ciphertext)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
createClientChannel,
|
||||
createDaemonChannel,
|
||||
Transport,
|
||||
} from "./encrypted-channel.js";
|
||||
import { generateKeyPair, exportPublicKey } from "./crypto.js";
|
||||
|
||||
/**
|
||||
* Creates a pair of connected mock transports.
|
||||
* Messages sent on one are received on the other.
|
||||
*/
|
||||
function createMockTransportPair(): [Transport, Transport] {
|
||||
const transportA: Transport = {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
const transportB: Transport = {
|
||||
send: vi.fn(),
|
||||
close: vi.fn(),
|
||||
onmessage: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
};
|
||||
|
||||
// Wire them together
|
||||
(transportA.send as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(data: string | ArrayBuffer) => {
|
||||
setTimeout(() => transportB.onmessage?.(data), 0);
|
||||
}
|
||||
);
|
||||
|
||||
(transportB.send as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(data: string | ArrayBuffer) => {
|
||||
setTimeout(() => transportA.onmessage?.(data), 0);
|
||||
}
|
||||
);
|
||||
|
||||
return [transportA, transportB];
|
||||
}
|
||||
|
||||
describe("EncryptedChannel", () => {
|
||||
it("establishes encrypted channel between daemon and client", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
// Daemon generates keypair (public key goes in QR)
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
// Start daemon waiting for client
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||
|
||||
// Client connects (scanned QR, got daemon's public key)
|
||||
const clientChannel = await createClientChannel(
|
||||
clientTransport,
|
||||
daemonPubKeyB64
|
||||
);
|
||||
|
||||
// Daemon receives hello and completes handshake
|
||||
const daemonChannel = await daemonChannelPromise;
|
||||
|
||||
expect(clientChannel.isOpen()).toBe(true);
|
||||
expect(daemonChannel.isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it("exchanges encrypted messages bidirectionally", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
const daemonMessages: (string | ArrayBuffer)[] = [];
|
||||
const clientMessages: (string | ArrayBuffer)[] = [];
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(
|
||||
daemonTransport,
|
||||
daemonKeyPair,
|
||||
{ onmessage: (data) => daemonMessages.push(data) }
|
||||
);
|
||||
|
||||
const clientChannel = await createClientChannel(
|
||||
clientTransport,
|
||||
daemonPubKeyB64,
|
||||
{ onmessage: (data) => clientMessages.push(data) }
|
||||
);
|
||||
|
||||
const daemonChannel = await daemonChannelPromise;
|
||||
|
||||
// Send messages both directions
|
||||
await clientChannel.send("Hello from client");
|
||||
await daemonChannel.send("Hello from daemon");
|
||||
await clientChannel.send("Second message from client");
|
||||
|
||||
// Wait for async delivery
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
expect(daemonMessages).toEqual([
|
||||
"Hello from client",
|
||||
"Second message from client",
|
||||
]);
|
||||
expect(clientMessages).toEqual(["Hello from daemon"]);
|
||||
});
|
||||
|
||||
it("encrypted messages are opaque to transport", async () => {
|
||||
const [daemonTransport, clientTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||
const clientChannel = await createClientChannel(
|
||||
clientTransport,
|
||||
daemonPubKeyB64
|
||||
);
|
||||
await daemonChannelPromise;
|
||||
|
||||
// Clear mock call history
|
||||
(clientTransport.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
// Send a plaintext message
|
||||
const plaintext = "Secret message";
|
||||
await clientChannel.send(plaintext);
|
||||
|
||||
// Check what was actually sent over the transport
|
||||
expect(clientTransport.send).toHaveBeenCalledTimes(1);
|
||||
const sentData = (clientTransport.send as ReturnType<typeof vi.fn>).mock
|
||||
.calls[0][0];
|
||||
|
||||
// Should be base64 string (encrypted)
|
||||
expect(typeof sentData).toBe("string");
|
||||
// Should NOT contain the plaintext
|
||||
expect(sentData).not.toContain(plaintext);
|
||||
// Should be significantly longer than plaintext (IV + auth tag overhead)
|
||||
expect(sentData.length).toBeGreaterThan(plaintext.length + 20);
|
||||
});
|
||||
|
||||
it("fails handshake on invalid hello", async () => {
|
||||
const [daemonTransport] = createMockTransportPair();
|
||||
|
||||
const daemonKeyPair = await generateKeyPair();
|
||||
|
||||
const daemonChannelPromise = createDaemonChannel(daemonTransport, daemonKeyPair);
|
||||
|
||||
// Send invalid hello
|
||||
setTimeout(() => {
|
||||
daemonTransport.onmessage?.('{"type":"invalid"}');
|
||||
}, 0);
|
||||
|
||||
await expect(daemonChannelPromise).rejects.toThrow("Invalid hello message");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Encrypted channel that wraps a WebSocket-like transport.
|
||||
*
|
||||
* Handles ECDH handshake and encrypts/decrypts all messages.
|
||||
* Works identically for daemon and client sides.
|
||||
*/
|
||||
|
||||
import {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
export interface Transport {
|
||||
send(data: string | ArrayBuffer): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
onmessage: ((data: string | ArrayBuffer) => void) | null;
|
||||
onclose: ((code: number, reason: string) => void) | null;
|
||||
onerror: ((error: Error) => void) | null;
|
||||
}
|
||||
|
||||
export interface EncryptedChannelEvents {
|
||||
onopen?: () => void;
|
||||
onmessage?: (data: string | ArrayBuffer) => void;
|
||||
onclose?: (code: number, reason: string) => void;
|
||||
onerror?: (error: Error) => void;
|
||||
}
|
||||
|
||||
type ChannelState = "connecting" | "handshaking" | "open" | "closed";
|
||||
|
||||
interface HelloMessage {
|
||||
type: "hello";
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encrypted channel as the initiator (client).
|
||||
*
|
||||
* The client:
|
||||
* 1. Receives daemon's public key via QR code
|
||||
* 2. Generates own keypair
|
||||
* 3. Sends hello with own public key
|
||||
* 4. Derives shared key and starts encrypted communication
|
||||
*/
|
||||
export async function createClientChannel(
|
||||
transport: Transport,
|
||||
daemonPublicKeyB64: string,
|
||||
events: EncryptedChannelEvents = {}
|
||||
): Promise<EncryptedChannel> {
|
||||
const keyPair = await generateKeyPair();
|
||||
const daemonPublicKey = await importPublicKey(daemonPublicKeyB64);
|
||||
const sharedKey = await deriveSharedKey(keyPair.privateKey, daemonPublicKey);
|
||||
|
||||
const channel = new EncryptedChannel(transport, sharedKey, events);
|
||||
|
||||
// Send hello with our public key
|
||||
const ourPublicKeyB64 = await exportPublicKey(keyPair.publicKey);
|
||||
const hello: HelloMessage = { type: "hello", key: ourPublicKeyB64 };
|
||||
transport.send(JSON.stringify(hello));
|
||||
|
||||
channel.setState("open");
|
||||
events.onopen?.();
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encrypted channel as the responder (daemon).
|
||||
*
|
||||
* The daemon:
|
||||
* 1. Has pre-generated keypair (public key was in QR)
|
||||
* 2. Waits for client's hello with their public key
|
||||
* 3. Derives shared key and starts encrypted communication
|
||||
*/
|
||||
export async function createDaemonChannel(
|
||||
transport: Transport,
|
||||
daemonKeyPair: CryptoKeyPair,
|
||||
events: EncryptedChannelEvents = {}
|
||||
): Promise<EncryptedChannel> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("Handshake timeout"));
|
||||
}, 10000);
|
||||
|
||||
transport.onmessage = async (data) => {
|
||||
try {
|
||||
if (typeof data !== "string") {
|
||||
throw new Error("Expected string hello message");
|
||||
}
|
||||
|
||||
const msg = JSON.parse(data) as HelloMessage;
|
||||
if (msg.type !== "hello" || !msg.key) {
|
||||
throw new Error("Invalid hello message");
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
const clientPublicKey = await importPublicKey(msg.key);
|
||||
const sharedKey = await deriveSharedKey(
|
||||
daemonKeyPair.privateKey,
|
||||
clientPublicKey
|
||||
);
|
||||
|
||||
const channel = new EncryptedChannel(transport, sharedKey, events);
|
||||
channel.setState("open");
|
||||
events.onopen?.();
|
||||
|
||||
resolve(channel);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
transport.onerror = (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transport.onclose = (code, reason) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Connection closed during handshake: ${code} ${reason}`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted channel that wraps a transport with E2EE.
|
||||
*/
|
||||
export class EncryptedChannel {
|
||||
private transport: Transport;
|
||||
private sharedKey: CryptoKey;
|
||||
private state: ChannelState = "handshaking";
|
||||
private events: EncryptedChannelEvents;
|
||||
|
||||
constructor(
|
||||
transport: Transport,
|
||||
sharedKey: CryptoKey,
|
||||
events: EncryptedChannelEvents = {}
|
||||
) {
|
||||
this.transport = transport;
|
||||
this.sharedKey = sharedKey;
|
||||
this.events = events;
|
||||
|
||||
transport.onmessage = (data) => this.handleMessage(data);
|
||||
transport.onclose = (code, reason) => {
|
||||
this.state = "closed";
|
||||
this.events.onclose?.(code, reason);
|
||||
};
|
||||
transport.onerror = (error) => {
|
||||
this.events.onerror?.(error);
|
||||
};
|
||||
}
|
||||
|
||||
setState(state: ChannelState): void {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
private async handleMessage(data: string | ArrayBuffer): Promise<void> {
|
||||
if (this.state !== "open") return;
|
||||
|
||||
try {
|
||||
const ciphertext =
|
||||
typeof data === "string" ? base64ToArrayBuffer(data) : data;
|
||||
const plaintext = await decrypt(this.sharedKey, ciphertext);
|
||||
this.events.onmessage?.(plaintext);
|
||||
} catch (error) {
|
||||
this.events.onerror?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async send(data: string | ArrayBuffer): Promise<void> {
|
||||
if (this.state !== "open") {
|
||||
throw new Error("Channel not open");
|
||||
}
|
||||
|
||||
const ciphertext = await encrypt(this.sharedKey, data);
|
||||
// Send as base64 for WebSocket text compatibility
|
||||
this.transport.send(arrayBufferToBase64(ciphertext));
|
||||
}
|
||||
|
||||
close(code = 1000, reason = "Normal closure"): void {
|
||||
this.state = "closed";
|
||||
this.transport.close(code, reason);
|
||||
}
|
||||
|
||||
isOpen(): boolean {
|
||||
return this.state === "open";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export { Relay } from "./relay.js";
|
||||
export type {
|
||||
ConnectionRole,
|
||||
RelayConnection,
|
||||
RelayEvents,
|
||||
RelaySession,
|
||||
RelaySessionAttachment,
|
||||
} from "./types.js";
|
||||
|
||||
export {
|
||||
generateKeyPair,
|
||||
exportPublicKey,
|
||||
importPublicKey,
|
||||
deriveSharedKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
} from "./crypto.js";
|
||||
|
||||
export {
|
||||
createClientChannel,
|
||||
createDaemonChannel,
|
||||
EncryptedChannel,
|
||||
} from "./encrypted-channel.js";
|
||||
export type { Transport, EncryptedChannelEvents } from "./encrypted-channel.js";
|
||||
@@ -0,0 +1,135 @@
|
||||
import http from "http";
|
||||
import { WebSocketServer, WebSocket as NodeWebSocket } from "ws";
|
||||
import { Relay } from "./relay.js";
|
||||
import type { ConnectionRole, RelayConnection } from "./types.js";
|
||||
|
||||
export interface NodeRelayServerConfig {
|
||||
port: number;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone Node.js relay server for self-hosting.
|
||||
*
|
||||
* This is a separate process that bridges daemon↔client connections.
|
||||
* Use this when you want to self-host a relay instead of using Cloudflare.
|
||||
*
|
||||
* Usage:
|
||||
* ```ts
|
||||
* const server = createRelayServer({ port: 8080 });
|
||||
* await server.start();
|
||||
* ```
|
||||
*
|
||||
* Clients connect via:
|
||||
* - ws://host:port/ws?session=abc&role=server
|
||||
* - ws://host:port/ws?session=abc&role=client
|
||||
*/
|
||||
export interface RelayServer {
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getRelay(): Relay;
|
||||
}
|
||||
|
||||
export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
|
||||
const { port, host = "0.0.0.0" } = config;
|
||||
|
||||
const relay = new Relay({
|
||||
onSessionCreated: (id) => console.log(`[Relay] Session created: ${id}`),
|
||||
onSessionBridged: (id) => console.log(`[Relay] Session bridged: ${id}`),
|
||||
onSessionClosed: (id) => console.log(`[Relay] Session closed: ${id}`),
|
||||
onError: (id, err) => console.error(`[Relay] Session ${id} error:`, err),
|
||||
});
|
||||
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
if (req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server: httpServer,
|
||||
path: "/ws",
|
||||
verifyClient: ({ req }, callback) => {
|
||||
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
const session = url.searchParams.get("session");
|
||||
const role = url.searchParams.get("role");
|
||||
|
||||
if (!session || !role || (role !== "server" && role !== "client")) {
|
||||
callback(false, 400, "Missing or invalid session/role parameters");
|
||||
return;
|
||||
}
|
||||
|
||||
callback(true);
|
||||
},
|
||||
});
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
|
||||
const sessionId = url.searchParams.get("session")!;
|
||||
const role = url.searchParams.get("role") as ConnectionRole;
|
||||
|
||||
const connection = wrapWebSocket(ws);
|
||||
relay.addConnection(sessionId, role, connection);
|
||||
|
||||
ws.on("message", (data) => {
|
||||
const message =
|
||||
data instanceof Buffer
|
||||
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)
|
||||
: String(data);
|
||||
relay.forward(sessionId, role, message as string | ArrayBuffer);
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
relay.removeConnection(sessionId, role);
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
console.error(`[Relay] WebSocket error for ${sessionId}/${role}:`, error);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
start() {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpServer.on("error", reject);
|
||||
httpServer.listen(port, host, () => {
|
||||
console.log(`[Relay] Listening on ${host}:${port}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
stop() {
|
||||
return new Promise((resolve) => {
|
||||
for (const session of relay.listSessions()) {
|
||||
relay.closeSession(session.id, 1001, "Server shutting down");
|
||||
}
|
||||
wss.close(() => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getRelay() {
|
||||
return relay;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function wrapWebSocket(ws: NodeWebSocket): RelayConnection {
|
||||
return {
|
||||
role: "server",
|
||||
send: (data) => {
|
||||
if (ws.readyState === NodeWebSocket.OPEN) {
|
||||
ws.send(data);
|
||||
}
|
||||
},
|
||||
close: (code, reason) => {
|
||||
ws.close(code, reason);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
ConnectionRole,
|
||||
RelayConnection,
|
||||
RelayEvents,
|
||||
RelaySession,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Core relay logic for bridging server and client WebSocket connections.
|
||||
*
|
||||
* This class is platform-agnostic and works with any WebSocket implementation
|
||||
* that conforms to the RelayConnection interface.
|
||||
*/
|
||||
export class Relay {
|
||||
private sessions = new Map<string, RelaySession>();
|
||||
private events: RelayEvents;
|
||||
|
||||
constructor(events: RelayEvents = {}) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a connection for a session.
|
||||
* If both server and client are connected, messages are bridged.
|
||||
*/
|
||||
addConnection(
|
||||
sessionId: string,
|
||||
role: ConnectionRole,
|
||||
connection: RelayConnection
|
||||
): void {
|
||||
let session = this.sessions.get(sessionId);
|
||||
|
||||
if (!session) {
|
||||
session = {
|
||||
id: sessionId,
|
||||
server: null,
|
||||
client: null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
this.events.onSessionCreated?.(sessionId);
|
||||
}
|
||||
|
||||
const existingConnection = session[role];
|
||||
if (existingConnection) {
|
||||
existingConnection.close(1008, "Replaced by new connection");
|
||||
}
|
||||
|
||||
session[role] = connection;
|
||||
|
||||
if (session.server && session.client) {
|
||||
this.events.onSessionBridged?.(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a connection from a session.
|
||||
* If both connections are gone, the session is cleaned up.
|
||||
*/
|
||||
removeConnection(sessionId: string, role: ConnectionRole): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session[role] = null;
|
||||
|
||||
if (!session.server && !session.client) {
|
||||
this.sessions.delete(sessionId);
|
||||
this.events.onSessionClosed?.(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a message from one side to the other.
|
||||
*/
|
||||
forward(
|
||||
sessionId: string,
|
||||
fromRole: ConnectionRole,
|
||||
data: string | ArrayBuffer
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const target = fromRole === "server" ? session.client : session.server;
|
||||
if (target) {
|
||||
try {
|
||||
target.send(data);
|
||||
} catch (error) {
|
||||
this.events.onError?.(
|
||||
sessionId,
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session info for debugging/monitoring.
|
||||
*/
|
||||
getSession(sessionId: string): RelaySession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active sessions.
|
||||
*/
|
||||
listSessions(): RelaySession[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a session and both connections.
|
||||
*/
|
||||
closeSession(sessionId: string, code = 1000, reason = "Session closed"): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
session.server?.close(code, reason);
|
||||
session.client?.close(code, reason);
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
this.events.onSessionClosed?.(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore sessions from persisted state (for Durable Objects hibernation).
|
||||
*/
|
||||
restoreSessions(sessions: RelaySession[]): void {
|
||||
for (const session of sessions) {
|
||||
this.sessions.set(session.id, session);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Relay connection types and interfaces.
|
||||
*
|
||||
* The relay bridges two WebSocket connections:
|
||||
* - Server (daemon): The Paseo server connecting to the relay
|
||||
* - Client (app): The mobile/web app connecting to the relay
|
||||
*
|
||||
* Messages are forwarded bidirectionally without modification.
|
||||
*/
|
||||
|
||||
export type ConnectionRole = "server" | "client";
|
||||
|
||||
export interface RelaySession {
|
||||
id: string;
|
||||
server: RelayConnection | null;
|
||||
client: RelayConnection | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RelayConnection {
|
||||
role: ConnectionRole;
|
||||
send(data: string | ArrayBuffer): void;
|
||||
close(code?: number, reason?: string): void;
|
||||
}
|
||||
|
||||
export interface RelaySessionAttachment {
|
||||
sessionId: string;
|
||||
role: ConnectionRole;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface RelayEvents {
|
||||
onSessionCreated?(sessionId: string): void;
|
||||
onSessionBridged?(sessionId: string): void;
|
||||
onSessionClosed?(sessionId: string): void;
|
||||
onError?(sessionId: string, error: Error): void;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,12 +1,42 @@
|
||||
import express, { type Express } from "express";
|
||||
import express from "express";
|
||||
import basicAuth from "express-basic-auth";
|
||||
import { createServer as createHTTPServer, type Server as HTTPServer } from "http";
|
||||
import { createReadStream } from "fs";
|
||||
import { createServer as createHTTPServer } from "http";
|
||||
import { createReadStream, unlinkSync, existsSync } from "fs";
|
||||
import { stat } from "fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
type ListenTarget =
|
||||
| { type: "tcp"; host: string; port: number }
|
||||
| { type: "socket"; path: string };
|
||||
|
||||
function parseListenString(listen: string): ListenTarget {
|
||||
// Unix socket: starts with / or ~ or contains .sock
|
||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
||||
return { type: "socket", path: listen };
|
||||
}
|
||||
// Explicit unix:// prefix
|
||||
if (listen.startsWith("unix://")) {
|
||||
return { type: "socket", path: listen.slice(7) };
|
||||
}
|
||||
// TCP: host:port or just port
|
||||
if (listen.includes(":")) {
|
||||
const [host, portStr] = listen.split(":");
|
||||
const port = parseInt(portStr, 10);
|
||||
if (!Number.isFinite(port)) {
|
||||
throw new Error(`Invalid port in listen string: ${listen}`);
|
||||
}
|
||||
return { type: "tcp", host: host || "127.0.0.1", port };
|
||||
}
|
||||
// Just a port number
|
||||
const port = parseInt(listen, 10);
|
||||
if (Number.isFinite(port)) {
|
||||
return { type: "tcp", host: "127.0.0.1", port };
|
||||
}
|
||||
throw new Error(`Invalid listen string: ${listen}`);
|
||||
}
|
||||
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { initializeSTT, type STTConfig } from "./agent/stt-openai.js";
|
||||
@@ -40,8 +70,9 @@ export type PaseoOpenAIConfig = {
|
||||
};
|
||||
|
||||
export type PaseoDaemonConfig = {
|
||||
port: number;
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
corsAllowedOrigins: string[];
|
||||
agentMcpRoute: string;
|
||||
agentMcpAllowedHosts: string[];
|
||||
auth: PaseoAuthConfig;
|
||||
@@ -54,18 +85,17 @@ export type PaseoDaemonConfig = {
|
||||
downloadTokenTtlMs?: number;
|
||||
};
|
||||
|
||||
export type PaseoDaemonHandles = {
|
||||
httpServer: HTTPServer;
|
||||
app: Express;
|
||||
wsServer: VoiceAssistantWebSocketServer;
|
||||
export interface PaseoDaemon {
|
||||
config: PaseoDaemonConfig;
|
||||
agentManager: AgentManager;
|
||||
agentRegistry: AgentRegistry;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function createPaseoDaemon(
|
||||
config: PaseoDaemonConfig
|
||||
): Promise<PaseoDaemonHandles> {
|
||||
): Promise<PaseoDaemon> {
|
||||
const agentMcpRoute = config.agentMcpRoute;
|
||||
const basicAuthUsers = config.auth.basicUsers;
|
||||
const staticDir = config.staticDir;
|
||||
@@ -76,8 +106,38 @@ export async function createPaseoDaemon(
|
||||
|
||||
const downloadTokenStore = new DownloadTokenStore({ ttlMs: downloadTokenTtlMs });
|
||||
|
||||
const listenTarget = parseListenString(config.listen);
|
||||
|
||||
const app = express();
|
||||
|
||||
// CORS - allow same-origin + configured origins
|
||||
const allowedOrigins = new Set([
|
||||
...config.corsAllowedOrigins,
|
||||
// For TCP, add localhost variants
|
||||
...(listenTarget.type === "tcp"
|
||||
? [
|
||||
`http://${listenTarget.host}:${listenTarget.port}`,
|
||||
`http://localhost:${listenTarget.port}`,
|
||||
`http://127.0.0.1:${listenTarget.port}`,
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
if (origin && allowedOrigins.has(origin)) {
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.setHeader("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
if (req.method === "OPTIONS") {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Serve static files from public directory (no auth required for APK downloads)
|
||||
app.use("/public", express.static(staticDir));
|
||||
|
||||
@@ -314,7 +374,8 @@ export async function createPaseoDaemon(
|
||||
{
|
||||
agentMcpUrl: config.agentControlMcp.url,
|
||||
agentMcpHeaders: config.agentControlMcp.headers,
|
||||
}
|
||||
},
|
||||
{ allowedOrigins }
|
||||
);
|
||||
|
||||
const openaiApiKey = config.openai?.apiKey;
|
||||
@@ -349,21 +410,56 @@ export async function createPaseoDaemon(
|
||||
);
|
||||
}
|
||||
|
||||
const close = async () => {
|
||||
const start = async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
httpServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
if (listenTarget.type === "tcp") {
|
||||
console.log(
|
||||
`✓ Server listening on http://${listenTarget.host}:${listenTarget.port}`
|
||||
);
|
||||
} else {
|
||||
console.log(`✓ Server listening on ${listenTarget.path}`);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
httpServer.once("error", onError);
|
||||
httpServer.once("listening", onListening);
|
||||
|
||||
if (listenTarget.type === "tcp") {
|
||||
httpServer.listen(listenTarget.port, listenTarget.host);
|
||||
} else {
|
||||
// Remove stale socket file if it exists
|
||||
if (existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
httpServer.listen(listenTarget.path);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
await closeAllAgents(agentManager);
|
||||
await wsServer.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
// Clean up socket file
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
httpServer,
|
||||
app,
|
||||
wsServer,
|
||||
config,
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
close,
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,17 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
import type { PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import type { STTConfig } from "./agent/stt-openai.js";
|
||||
import type { TTSConfig } from "./agent/tts-openai.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
|
||||
function expandHomeDir(input: string): string {
|
||||
if (input.startsWith("~/")) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
if (input === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function readPaseoHomeFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): string {
|
||||
const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo";
|
||||
const expanded = path.resolve(expandHomeDir(raw));
|
||||
mkdirSync(expanded, { recursive: true });
|
||||
return expanded;
|
||||
}
|
||||
|
||||
export function readPaseoPortFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): number {
|
||||
const raw = env.PASEO_PORT ?? env.PORT ?? "6767";
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 6767;
|
||||
}
|
||||
|
||||
const DEFAULT_LISTEN = "127.0.0.1:6767";
|
||||
const DEFAULT_BASIC_AUTH_USERS = { mo: "bo" } as const;
|
||||
const DEFAULT_AGENT_MCP_ROUTE = "/mcp/agents";
|
||||
|
||||
function readOpenAIConfigFromEnv(env: NodeJS.ProcessEnv = process.env) {
|
||||
function parseOpenAIConfig(env: NodeJS.ProcessEnv) {
|
||||
const apiKey = env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
if (!apiKey) return undefined;
|
||||
|
||||
const sttConfidenceThreshold = env.STT_CONFIDENCE_THRESHOLD
|
||||
? parseFloat(env.STT_CONFIDENCE_THRESHOLD)
|
||||
@@ -71,15 +42,34 @@ function readOpenAIConfigFromEnv(env: NodeJS.ProcessEnv = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPaseoDaemonConfigFromEnv(
|
||||
function getListenForMcp(listen: string): string {
|
||||
// For Unix sockets, MCP still needs HTTP - use localhost
|
||||
if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) {
|
||||
return "127.0.0.1:6767";
|
||||
}
|
||||
if (listen.startsWith("unix://")) {
|
||||
return "127.0.0.1:6767";
|
||||
}
|
||||
// TCP: extract host:port
|
||||
if (listen.includes(":")) {
|
||||
const [host, port] = listen.split(":");
|
||||
return `${host || "127.0.0.1"}:${port}`;
|
||||
}
|
||||
// Just port
|
||||
return `127.0.0.1:${listen}`;
|
||||
}
|
||||
|
||||
export function loadConfig(
|
||||
paseoHome: string,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): PaseoDaemonConfig {
|
||||
const paseoHome = readPaseoHomeFromEnv(env);
|
||||
const port = readPaseoPortFromEnv(env);
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
|
||||
const listen = env.PASEO_LISTEN ?? persisted.listen ?? DEFAULT_LISTEN;
|
||||
const mcpListen = getListenForMcp(listen);
|
||||
|
||||
const basicUsers = DEFAULT_BASIC_AUTH_USERS;
|
||||
const [agentMcpUser, agentMcpPassword] =
|
||||
Object.entries(basicUsers)[0] ?? [];
|
||||
const [agentMcpUser, agentMcpPassword] = Object.entries(basicUsers)[0] ?? [];
|
||||
const agentMcpAuthHeader =
|
||||
agentMcpUser && agentMcpPassword
|
||||
? `Basic ${Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")}`
|
||||
@@ -89,13 +79,12 @@ export function buildPaseoDaemonConfigFromEnv(
|
||||
? Buffer.from(`${agentMcpUser}:${agentMcpPassword}`).toString("base64")
|
||||
: undefined;
|
||||
|
||||
const openai = readOpenAIConfigFromEnv(env);
|
||||
|
||||
return {
|
||||
port,
|
||||
listen,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: persisted.cors.allowedOrigins,
|
||||
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
|
||||
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
||||
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
|
||||
auth: {
|
||||
basicUsers,
|
||||
agentMcpAuthHeader,
|
||||
@@ -104,7 +93,7 @@ export function buildPaseoDaemonConfigFromEnv(
|
||||
},
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
agentControlMcp: {
|
||||
url: `http://127.0.0.1:${port}${DEFAULT_AGENT_MCP_ROUTE}`,
|
||||
url: `http://${mcpListen}${DEFAULT_AGENT_MCP_ROUTE}`,
|
||||
...(agentMcpAuthHeader
|
||||
? { headers: { Authorization: agentMcpAuthHeader } }
|
||||
: {}),
|
||||
@@ -112,6 +101,6 @@ export function buildPaseoDaemonConfigFromEnv(
|
||||
agentRegistryPath: path.join(paseoHome, "agents.json"),
|
||||
staticDir: "public",
|
||||
agentClients: {},
|
||||
openai,
|
||||
openai: parseOpenAIConfig(env),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import "dotenv/config";
|
||||
import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { buildPaseoDaemonConfigFromEnv } from "./config.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { resolvePaseoHome } from "./paseo-home.js";
|
||||
|
||||
async function main() {
|
||||
const daemonConfig = buildPaseoDaemonConfigFromEnv();
|
||||
const daemon = await createPaseoDaemon(daemonConfig);
|
||||
const paseoHome = resolvePaseoHome();
|
||||
const config = loadConfig(paseoHome);
|
||||
const daemon = await createPaseoDaemon(config);
|
||||
|
||||
await daemon.start();
|
||||
|
||||
daemon.httpServer.listen(daemonConfig.port, () => {
|
||||
console.log(
|
||||
`\n✓ Voice Assistant server running on http://localhost:${daemonConfig.port}`
|
||||
);
|
||||
});
|
||||
const handleShutdown = async (signal: string) => {
|
||||
console.log(`\n${signal} received, shutting down gracefully...`);
|
||||
|
||||
@@ -20,7 +19,7 @@ async function main() {
|
||||
}, 10000);
|
||||
|
||||
try {
|
||||
await daemon.close();
|
||||
await daemon.stop();
|
||||
clearTimeout(forceExit);
|
||||
console.log("Server closed");
|
||||
process.exit(0);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
function expandHomeDir(input: string): string {
|
||||
if (input.startsWith("~/")) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
if (input === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function resolvePaseoHome(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const raw = env.PASEO_HOME ?? env.PASEO_HOME_DIR ?? "~/.paseo";
|
||||
const resolved = path.resolve(expandHomeDir(raw));
|
||||
mkdirSync(resolved, { recursive: true });
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
const PersistedConfigSchema = z.object({
|
||||
listen: z.string().optional(),
|
||||
cors: z
|
||||
.object({
|
||||
allowedOrigins: z.array(z.string()).default([]),
|
||||
})
|
||||
.default({}),
|
||||
});
|
||||
|
||||
export type PersistedConfig = z.infer<typeof PersistedConfigSchema>;
|
||||
|
||||
const CONFIG_FILENAME = "config.json";
|
||||
|
||||
function getConfigPath(paseoHome: string): string {
|
||||
return path.join(paseoHome, CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
export function loadPersistedConfig(paseoHome: string): PersistedConfig {
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
console.log(`[Config] No config file at ${configPath}, using defaults`);
|
||||
return PersistedConfigSchema.parse({});
|
||||
}
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, "utf-8");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`[Config] Failed to read ${configPath}: ${message}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`);
|
||||
}
|
||||
|
||||
const result = PersistedConfigSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`[Config] Invalid config in ${configPath}:\n${issues}`);
|
||||
}
|
||||
|
||||
console.log(`[Config] Loaded from ${configPath}`);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function savePersistedConfig(
|
||||
paseoHome: string,
|
||||
config: PersistedConfig
|
||||
): void {
|
||||
const configPath = getConfigPath(paseoHome);
|
||||
|
||||
const result = PersistedConfigSchema.safeParse(config);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`[Config] Invalid config to save:\n${issues}`);
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(configPath, JSON.stringify(result.data, null, 2) + "\n");
|
||||
console.log(`[Config] Saved to ${configPath}`);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`[Config] Failed to write ${configPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,9 @@ export async function createTestPaseoDaemon(
|
||||
const port = await getAvailablePort();
|
||||
|
||||
const config: PaseoDaemonConfig = {
|
||||
port,
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
agentMcpRoute: "/mcp/agents",
|
||||
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
||||
auth: {
|
||||
@@ -85,11 +86,10 @@ export async function createTestPaseoDaemon(
|
||||
|
||||
const daemon = await createPaseoDaemon(config);
|
||||
try {
|
||||
await listenOnPort(daemon.httpServer, port);
|
||||
await daemon.start();
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
await daemon.close().catch(() => undefined);
|
||||
// Wait a bit for file handles to release
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
@@ -107,7 +107,7 @@ export async function createTestPaseoDaemon(
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await daemon.close().catch(() => undefined);
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await rm(paseoHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
await rm(staticDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
||||
|
||||
@@ -120,30 +120,6 @@ export async function createTestPaseoDaemon(
|
||||
throw lastError ?? new Error("Failed to start test daemon");
|
||||
}
|
||||
|
||||
async function listenOnPort(server: net.Server, port: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => {
|
||||
server.off("listening", onListening);
|
||||
reject(error);
|
||||
};
|
||||
const onListening = () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
};
|
||||
|
||||
server.once("error", onError);
|
||||
server.once("listening", onListening);
|
||||
|
||||
try {
|
||||
server.listen(port);
|
||||
} catch (error) {
|
||||
server.off("error", onError);
|
||||
server.off("listening", onListening);
|
||||
reject(error instanceof Error ? error : new Error("Failed to listen"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isAddressInUseError(error: unknown): boolean {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
|
||||
@@ -22,6 +22,10 @@ type AgentMcpClientConfig = {
|
||||
agentMcpHeaders?: Record<string, string>;
|
||||
};
|
||||
|
||||
type WebSocketServerConfig = {
|
||||
allowedOrigins: Set<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebSocket server that routes messages between clients and their sessions.
|
||||
* This is a thin transport layer with no business logic.
|
||||
@@ -43,7 +47,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
agentManager: AgentManager,
|
||||
agentRegistry: AgentRegistry,
|
||||
downloadTokenStore: DownloadTokenStore,
|
||||
agentMcpConfig: AgentMcpClientConfig
|
||||
agentMcpConfig: AgentMcpClientConfig,
|
||||
wsConfig: WebSocketServerConfig
|
||||
) {
|
||||
this.agentManager = agentManager;
|
||||
this.agentRegistry = agentRegistry;
|
||||
@@ -51,7 +56,23 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.pushTokenStore = new PushTokenStore();
|
||||
this.pushService = new PushService(this.pushTokenStore);
|
||||
this.agentMcpConfig = agentMcpConfig;
|
||||
this.wss = new WebSocketServer({ server, path: "/ws" });
|
||||
|
||||
const { allowedOrigins } = wsConfig;
|
||||
this.wss = new WebSocketServer({
|
||||
server,
|
||||
path: "/ws",
|
||||
verifyClient: ({ req }, callback) => {
|
||||
const origin = req.headers.origin;
|
||||
// Allow connections with no origin (native apps, curl, etc.)
|
||||
// or from allowed origins (browsers)
|
||||
if (!origin || allowedOrigins.has(origin)) {
|
||||
callback(true);
|
||||
} else {
|
||||
console.warn(`[WebSocket] Rejected connection from origin: ${origin}`);
|
||||
callback(false, 403, "Origin not allowed");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.wss.on("connection", (ws, request) => {
|
||||
this.handleConnection(ws, request);
|
||||
|
||||
Reference in New Issue
Block a user