diff --git a/package-lock.json b/package-lock.json index d30631ffe..0ba125773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a44c666d3..1e73bd9c6 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "private": true, "workspaces": [ "packages/server", - "packages/app" + "packages/app", + "packages/relay" ], "scripts": { "dev": "npm run dev --workspace=@paseo/server", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index aa2783b43..8e7b3d889 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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 = ( - + {!isMobile && } {children} @@ -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 ; } @@ -207,10 +219,10 @@ function LoadingView() { flex: 1, justifyContent: "center", alignItems: "center", - backgroundColor: "#09090b", + backgroundColor: darkTheme.colors.surface0, }} > - + ); } @@ -223,13 +235,13 @@ function MissingDaemonView() { justifyContent: "center", alignItems: "center", padding: 24, - backgroundColor: "#09090b", + backgroundColor: darkTheme.colors.surface0, }} > - + ({ 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, }, })); diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index dfad79294..93e474397 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -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 } /> )} - + @@ -748,7 +748,7 @@ function AgentScreenContent({ <> Fetching… @@ -777,7 +777,7 @@ function AgentScreenContent({ {/* Sub-Agents Section */} - + Sub-Agents {childAgents.length === 0 ? ( @@ -807,7 +807,7 @@ function AgentScreenContent({ ) : null} @@ -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], }, diff --git a/packages/app/src/app/agents.tsx b/packages/app/src/app/agents.tsx index 76ed37aa1..0969322bd 100644 --- a/packages/app/src/app/agents.tsx +++ b/packages/app/src/app/agents.tsx @@ -46,6 +46,6 @@ export default function AgentsScreen() { const styles = StyleSheet.create((theme) => ({ container: { flex: 1, - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.surface0, }, })); diff --git a/packages/app/src/app/audio-test.tsx b/packages/app/src/app/audio-test.tsx index 718dff6ac..6e154ed95 100644 --- a/packages/app/src/app/audio-test.tsx +++ b/packages/app/src/app/audio-test.tsx @@ -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], diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 53e639b75..3911956ac 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -538,7 +538,7 @@ export default function HomeScreen() { style={styles.hostBadge} onPress={() => openDropdownSheet("host")} > - + {hostLabel} ({ 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], diff --git a/packages/app/src/app/orchestrator.tsx b/packages/app/src/app/orchestrator.tsx index 65cd3963b..505e78104 100644 --- a/packages/app/src/app/orchestrator.tsx +++ b/packages/app/src/app/orchestrator.tsx @@ -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, diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 695fef053..917291845 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -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() { )} + {/* Appearance */} + + Appearance + + handleThemeChange("light")} + > + + + Light + + + handleThemeChange("dark")} + > + + + Dark + + + handleThemeChange("auto")} + > + + + System + + + + + {/* Audio Settings */} Audio @@ -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 ( @@ -911,7 +997,7 @@ function DaemonCard({ disabled={isRestarting} > {isRestarting ? ( - + ) : ( Restart )} diff --git a/packages/app/src/components/active-processes.tsx b/packages/app/src/components/active-processes.tsx index c30c5403b..2f9769552 100644 --- a/packages/app/src/components/active-processes.tsx +++ b/packages/app/src/components/active-processes.tsx @@ -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, diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx index 8a75366a2..635790af9 100644 --- a/packages/app/src/components/agent-activity.tsx +++ b/packages/app/src/components/agent-activity.tsx @@ -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, }, })); diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index bb6444ff9..967b09f76 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -87,7 +87,7 @@ export function DropdownField({ > {value || placeholder} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : null} @@ -138,7 +138,7 @@ export function SelectField({ {value || placeholder || "Select..."} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : 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({ > {label} {isLoading ? ( - + ) : ( - + ) : ( {displayBranch} - + )} @@ -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, }, })); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 42660dee4..96450802e 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -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], diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index cffdd1ef7..d86bd96c0 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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 ( ) : 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, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index c48ea2c7a..ae76224a3 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -52,7 +52,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { agent.currentModeId || "default"} - + )} @@ -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", diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 28e0c91cb..55544264b 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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) => { 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={ @@ -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({ {description} @@ -1032,7 +991,7 @@ function PermissionRequestCard({ 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({ Command @@ -1068,7 +1027,7 @@ function PermissionRequestCard({ Command @@ -1088,7 +1047,7 @@ function PermissionRequestCard({ Directory @@ -1111,7 +1070,7 @@ function PermissionRequestCard({ 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({ 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({ 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", diff --git a/packages/app/src/components/artifact-drawer.tsx b/packages/app/src/components/artifact-drawer.tsx index c555c9e29..51fc81302 100644 --- a/packages/app/src/components/artifact-drawer.tsx +++ b/packages/app/src/components/artifact-drawer.tsx @@ -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, diff --git a/packages/app/src/components/audio-debug-notice.tsx b/packages/app/src/components/audio-debug-notice.tsx index 42b4be6a2..839974056 100644 --- a/packages/app/src/components/audio-debug-notice.tsx +++ b/packages/app/src/components/audio-debug-notice.tsx @@ -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, }, ]} > - {title} + {title} {onDismiss ? ( - + ) : null} @@ -131,13 +131,13 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: ) : ( - + Raw audio path unavailable. Set STT_DEBUG_AUDIO_DIR on the server to persist recordings. )} {stats ? ( - + {stats} ) : null} diff --git a/packages/app/src/components/command-autocomplete.tsx b/packages/app/src/components/command-autocomplete.tsx index 14751df10..fb83afafe 100644 --- a/packages/app/src/components/command-autocomplete.tsx +++ b/packages/app/src/components/command-autocomplete.tsx @@ -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; diff --git a/packages/app/src/components/conversation-selector.tsx b/packages/app/src/components/conversation-selector.tsx index fb703e97c..07a13a94d 100644 --- a/packages/app/src/components/conversation-selector.tsx +++ b/packages/app/src/components/conversation-selector.tsx @@ -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], }, diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index c40773ebf..a1faf7338 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -102,7 +102,7 @@ export function DictationControls({ accessibilityLabel="Retry dictation" style={[styles.actionButton, styles.actionButtonConfirm]} > - + ) : ( <> @@ -118,7 +118,7 @@ export function DictationControls({ accessibilityLabel="Insert transcription and send" style={[styles.actionButton, styles.actionButtonConfirm]} > - + )} @@ -165,7 +165,7 @@ export function DictationOverlay({ @@ -243,7 +243,7 @@ export function DictationOverlay({ > @@ -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, diff --git a/packages/app/src/components/dictation-status-notice.tsx b/packages/app/src/components/dictation-status-notice.tsx index 442ac7dd5..96d31d4a9 100644 --- a/packages/app/src/components/dictation-status-notice.tsx +++ b/packages/app/src/components/dictation-status-notice.tsx @@ -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 ( ({ 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, }, })); diff --git a/packages/app/src/components/empty-state.tsx b/packages/app/src/components/empty-state.tsx index 6ec39e6b1..1dff80b00 100644 --- a/packages/app/src/components/empty-state.tsx +++ b/packages/app/src/components/empty-state.tsx @@ -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, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index cb15b09ff..6f2d97f60 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -251,7 +251,7 @@ function SidebarContent({ color={ activeTab === "changes" ? theme.colors.foreground - : theme.colors.mutedForeground + : theme.colors.foregroundMuted } /> - + )} @@ -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, }, })); diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx index 09c4b441d..13dc3b9c3 100644 --- a/packages/app/src/components/file-drop-zone.tsx +++ b/packages/app/src/components/file-drop-zone.tsx @@ -80,7 +80,7 @@ const styles = StyleSheet.create((theme) => ({ }, backdrop: { ...StyleSheet.absoluteFillObject, - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.surface0, opacity: 0.7, }, overlayContent: { diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index be434114c..550edcf92 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -756,7 +756,7 @@ export function FileExplorerPane({ {currentSortLabel} - + {showListLoadingBanner && ( @@ -947,7 +947,7 @@ export function FileExplorerPane({ {selectedEntryPath?.split("/").pop() ?? "Preview"} - + {isPreviewLoading && !preview ? ( @@ -1030,7 +1030,7 @@ export function FileExplorerPane({ hitSlop={8} style={styles.downloadToastDismiss} > - + )} @@ -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", diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 43c09e106..3f3c69f9b 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -37,41 +37,63 @@ interface HighlightedTextProps { lineType: "add" | "remove" | "context" | "header"; } +// GitHub syntax highlight colors for dark/light modes +const darkHighlightColors: Record = { + 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 = { + 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 = { - 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({ > @@ -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, }, })); diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index a70c723da..b69d9481c 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -22,7 +22,7 @@ export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { onPress={onBack ?? (() => router.back())} style={styles.backButton} > - + {title && ( diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 1b48671b1..fa7330878 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -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 ( ({ header: { - backgroundColor: theme.colors.background, + backgroundColor: theme.colors.surface0, }, inner: {}, row: { diff --git a/packages/app/src/components/home-footer.tsx b/packages/app/src/components/home-footer.tsx index a32a680b3..141e15edb 100644 --- a/packages/app/src/components/home-footer.tsx +++ b/packages/app/src/components/home-footer.tsx @@ -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", }, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 196d0ca33..15387a1d2 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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", }, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 7658f0055..635439e12 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 ( ({ emptyText: { - color: theme.colors.mutedForeground, + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontStyle: "italic" as const, }, diff --git a/packages/app/src/components/mode-selector-modal.tsx b/packages/app/src/components/mode-selector-modal.tsx index 67f910412..f3d28ca8d 100644 --- a/packages/app/src/components/mode-selector-modal.tsx +++ b/packages/app/src/components/mode-selector-modal.tsx @@ -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: { diff --git a/packages/app/src/components/realtime-controls.tsx b/packages/app/src/components/realtime-controls.tsx index 234351255..6009f6c5b 100644 --- a/packages/app/src/components/realtime-controls.tsx +++ b/packages/app/src/components/realtime-controls.tsx @@ -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, }, diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index b602f470b..2d4d60bc9 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -226,7 +226,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { style={styles.settingsButton} onPress={handleSettingsMobile} > - + Settings @@ -273,7 +273,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { style={styles.settingsButton} onPress={handleSettingsDesktop} > - + Settings @@ -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, }, })); diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 3302ddf0f..0686a5557 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -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", }, diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index e67996612..e0abf535b 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -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 [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }], + [style, theme.colors.surface2] + ); + return ; +} + // ----- 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], diff --git a/packages/app/src/components/voice-button.tsx b/packages/app/src/components/voice-button.tsx index d9388fd0c..3fe678e4b 100644 --- a/packages/app/src/components/voice-button.tsx +++ b/packages/app/src/components/voice-button.tsx @@ -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 diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 05666b9f1..7912c5f40 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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, ]); diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store.ts new file mode 100644 index 000000000..ac61046da --- /dev/null +++ b/packages/app/src/stores/draft-store.ts @@ -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; + 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()( + 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), + } + ) +); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0c417e1ba..d2966e4ef 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -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; - // Top-level drafts (keyed by agentId, not serverId:agentId since agent IDs are globally unique) - drafts: Map; - - // Single draft for the create agent modal - createModalDraft: DraftInput | null; - // Agent activity timestamps (top-level, keyed by agentId to prevent cascade rerenders) agentLastActivity: Map; } @@ -305,14 +294,6 @@ interface SessionStoreActions { // Provider models setProviderModels: (serverId: string, models: Map | ((prev: Map) => Map)) => 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 }>> | ((prev: Map }>>) => Map }>>)) => void; @@ -392,8 +373,6 @@ function createInitialSessionState(serverId: string, client: DaemonClientV2, aud export const useSessionStore = create()( subscribeWithSelector((set, get) => ({ sessions: {}, - drafts: new Map(), - createModalDraft: null, agentLastActivity: new Map(), // Session management @@ -779,34 +758,6 @@ export const useSessionStore = create()( }); }, - // 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) => { diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index 6f0f117a1..1aa304ed9 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -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], diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 3ced85ae6..fd4511b5a 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -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; diff --git a/packages/relay/package.json b/packages/relay/package.json new file mode 100644 index 000000000..31205c40a --- /dev/null +++ b/packages/relay/package.json @@ -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" + } +} diff --git a/packages/relay/src/cloudflare-adapter.ts b/packages/relay/src/cloudflare-adapter.ts new file mode 100644 index 000000000..edc5891cf --- /dev/null +++ b/packages/relay/src/cloudflare-adapter.ts @@ -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; +} + +/** + * 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 { + 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 { + 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 }); + }, +}; diff --git a/packages/relay/src/crypto.test.ts b/packages/relay/src/crypto.test.ts new file mode 100644 index 000000000..1b6ebf3db --- /dev/null +++ b/packages/relay/src/crypto.test.ts @@ -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 + ); + }); + }); +}); diff --git a/packages/relay/src/crypto.ts b/packages/relay/src/crypto.ts new file mode 100644 index 000000000..8176b0e34 --- /dev/null +++ b/packages/relay/src/crypto.ts @@ -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 { + return crypto.subtle.generateKey(ECDH_ALGORITHM, true, ["deriveBits"]); +} + +/** + * Export a public key to base64 string (for transmission). + */ +export async function exportPublicKey(key: CryptoKey): Promise { + 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 { + 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 { + // 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 { + 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 { + 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; +} diff --git a/packages/relay/src/e2e.test.ts b/packages/relay/src/e2e.test.ts new file mode 100644 index 000000000..0fcc46280 --- /dev/null +++ b/packages/relay/src/e2e.test.ts @@ -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((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((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((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((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((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((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((r) => daemonWs.on("open", r)), + new Promise((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((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(); + }); +}); diff --git a/packages/relay/src/encrypted-channel.test.ts b/packages/relay/src/encrypted-channel.test.ts new file mode 100644 index 000000000..917dc0833 --- /dev/null +++ b/packages/relay/src/encrypted-channel.test.ts @@ -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).mockImplementation( + (data: string | ArrayBuffer) => { + setTimeout(() => transportB.onmessage?.(data), 0); + } + ); + + (transportB.send as ReturnType).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).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).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"); + }); +}); diff --git a/packages/relay/src/encrypted-channel.ts b/packages/relay/src/encrypted-channel.ts new file mode 100644 index 000000000..03e9d1fea --- /dev/null +++ b/packages/relay/src/encrypted-channel.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/packages/relay/src/index.ts b/packages/relay/src/index.ts new file mode 100644 index 000000000..8ae9211d7 --- /dev/null +++ b/packages/relay/src/index.ts @@ -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"; diff --git a/packages/relay/src/node-adapter.ts b/packages/relay/src/node-adapter.ts new file mode 100644 index 000000000..f20b5e489 --- /dev/null +++ b/packages/relay/src/node-adapter.ts @@ -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; + stop(): Promise; + 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); + }, + }; +} diff --git a/packages/relay/src/relay.ts b/packages/relay/src/relay.ts new file mode 100644 index 000000000..5cf676250 --- /dev/null +++ b/packages/relay/src/relay.ts @@ -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(); + 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); + } + } +} diff --git a/packages/relay/src/types.ts b/packages/relay/src/types.ts new file mode 100644 index 000000000..2cd3aa444 --- /dev/null +++ b/packages/relay/src/types.ts @@ -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; +} diff --git a/packages/relay/tsconfig.json b/packages/relay/tsconfig.json new file mode 100644 index 000000000..5485126b1 --- /dev/null +++ b/packages/relay/tsconfig.json @@ -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"] +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index c28565c2a..cfd337160 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.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; -}; + start(): Promise; + stop(): Promise; +} export async function createPaseoDaemon( config: PaseoDaemonConfig -): Promise { +): Promise { 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((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((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, }; } diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 5100eb89e..4da6e607a 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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), }; } diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index beba9ec77..0c2522c89 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -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); diff --git a/packages/server/src/server/paseo-home.ts b/packages/server/src/server/paseo-home.ts new file mode 100644 index 000000000..02e159ec0 --- /dev/null +++ b/packages/server/src/server/paseo-home.ts @@ -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; +} diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts new file mode 100644 index 000000000..db6724747 --- /dev/null +++ b/packages/server/src/server/persisted-config.ts @@ -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; + +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}`); + } +} diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 8e2588192..f3c2c2da3 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -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 => { - 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 { - await new Promise((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; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index e9dddc325..eadad0613 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -22,6 +22,10 @@ type AgentMcpClientConfig = { agentMcpHeaders?: Record; }; +type WebSocketServerConfig = { + allowedOrigins: Set; +}; + /** * 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);