Compare commits

..

2 Commits

Author SHA1 Message Date
Mohamed Boudra
d3876ffe61 Fix rebase type errors and add TODO date tag
- Await listAvailableEditorTargets() which became async on main
- Wrap sendAgentMessage callback to match Promise<void> return type
- Add TODO(2026-07) date to legacy editor target ids comment
2026-04-09 09:23:31 +07:00
Illia Panasenko
8f136a1f92 Add WebStorm editor target 2026-04-09 09:17:43 +07:00
30 changed files with 519 additions and 646 deletions

View File

@@ -1,21 +1,5 @@
# Changelog
## 0.1.51 - 2026-04-09
### Added
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
### Fixed
- Model selector no longer crashes on iPad.
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
- OpenCode agents show the correct terminal state and refresh models reliably.
- Follow-up messages to agents that just finished a turn now work correctly.
- Commands now load properly for Pi agents.
- Internal debug output no longer appears in Claude agent timelines.
- QR scan screen cleaned up with simpler visuals.
## 0.1.50 - 2026-04-07
### Added

38
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -34906,16 +34906,16 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.51",
"@getpaseo/highlight": "0.1.51",
"@getpaseo/server": "0.1.51",
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",
@@ -35032,11 +35032,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.51",
"@getpaseo/server": "0.1.51",
"@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -35077,11 +35077,11 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "0.1.51",
"@getpaseo/server": "0.1.51",
"@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"
@@ -35115,7 +35115,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"license": "MIT",
"devDependencies": {
"@biomejs/biome": "1.9.4",
@@ -35316,7 +35316,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -35342,7 +35342,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -35358,14 +35358,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.51",
"@getpaseo/relay": "0.1.51",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",
@@ -35764,7 +35764,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"private": true,
"workspaces": [
"packages/expo-two-way-audio",

View File

@@ -1,7 +1,7 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"private": true,
"scripts": {
"start": "expo start",
@@ -31,9 +31,9 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/expo-two-way-audio": "0.1.51",
"@getpaseo/highlight": "0.1.51",
"@getpaseo/server": "0.1.51",
"@getpaseo/expo-two-way-audio": "0.1.51-rc.1",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@react-native-async-storage/async-storage": "2.2.0",

View File

@@ -6,6 +6,8 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { NameHostModal } from "@/components/name-host-modal";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
@@ -59,7 +61,7 @@ const styles = StyleSheet.create((theme) => ({
position: "absolute",
width: 36,
height: 36,
borderColor: theme.colors.accent,
borderColor: theme.colors.palette.blue[400],
},
cornerTL: {
left: 0,
@@ -146,11 +148,28 @@ export default function PairScanScreen() {
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
const returnToSource = useCallback(
(serverId: string) => {
@@ -205,6 +224,7 @@ export default function PairScanScreen() {
const handleScan = useCallback(
async (result: BarcodeScanningResult) => {
if (pendingNameHost) return;
if (isPairing) return;
const offerUrl = extractOfferUrlFromScan(result);
if (!offerUrl) return;
@@ -228,7 +248,7 @@ export default function PairScanScreen() {
return;
}
const { client, hostname } = await connectToDaemon(
const { client } = await connectToDaemon(
{
id: "probe",
type: "relay",
@@ -239,7 +259,13 @@ export default function PairScanScreen() {
);
await client.close().catch(() => undefined);
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === offer.serverId);
const profile = await upsertDaemonFromOfferUrl(offerUrl);
if (isNewHost) {
setPendingNameHost({ serverId: profile.serverId, hostname: null });
return;
}
returnToSource(profile.serverId);
} catch (error) {
@@ -250,7 +276,7 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
[daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
);
if (Platform.OS === "web") {
@@ -281,6 +307,25 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
{pendingNameHost ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
const serverId = pendingNameHost.serverId;
setPendingNameHost(null);
returnToSource(serverId);
}}
onSave={(label) => {
const serverId = pendingNameHost.serverId;
void renameHost(serverId, label).finally(() => {
setPendingNameHost(null);
returnToSource(serverId);
});
}}
/>
) : null}
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}>
@@ -314,6 +359,7 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
</View>
<Text style={styles.helperText}>Point your camera at the pairing QR code.</Text>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing

View File

@@ -218,7 +218,6 @@ export function AddHostModal({
const profile = await upsertDirectConnection({
serverId,
endpoint,
label: hostname ?? undefined,
});
onSaved?.({ profile, serverId, hostname, isNewHost });

View File

@@ -48,7 +48,6 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
type QueuedMessage = {
id: string;
@@ -132,8 +131,6 @@ export function AgentInputArea({
agentDirectoryStatus === "revalidating" ||
agentDirectoryStatus === "error_after_ready");
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
@@ -745,7 +742,6 @@ export function AgentInputArea({
voiceServerId={serverId}
voiceAgentId={agentId}
isAgentRunning={isAgentRunning}
defaultSendBehavior={appSettings.sendBehavior}
onQueue={handleQueue}
onSubmitLoadingPress={isAgentRunning ? handleCancelAgent : undefined}
onKeyPress={handleCommandKeyPress}

View File

@@ -9,7 +9,7 @@ import {
type GestureResponderEvent,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
ArrowLeft,
ChevronDown,
@@ -348,9 +348,7 @@ function ProviderSearchInput({
}) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const InputComponent = isMobile ? BottomSheetTextInput : TextInput;
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
useEffect(() => {
if (autoFocus && Platform.OS === "web" && inputRef.current) {

View File

@@ -87,9 +87,6 @@ export interface MessageInputProps {
voiceAgentId?: string;
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean;
/** Controls what the default send action (Enter, send button, dictation) does
* when the agent is running. "interrupt" sends immediately, "queue" queues. */
defaultSendBehavior?: "interrupt" | "queue";
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void;
/** Optional handler used when submit button is in loading state. */
@@ -211,7 +208,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
voiceServerId,
voiceAgentId,
isAgentRunning = false,
defaultSendBehavior = "interrupt",
onQueue,
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
@@ -356,17 +352,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined;
// Respect send behavior setting: when "queue", dictation queues too.
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
onQueue({ text: nextValue, images: imageAttachments });
onChangeText("");
} else {
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
}
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
});
} else {
onChangeText(nextValue);
}
@@ -377,7 +367,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
});
}
},
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
[onChangeText, onSubmit, images, isAgentRunning],
);
const handleDictationError = useCallback(
@@ -588,26 +578,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onQueue, onChangeText, onHeightChange]);
// Default send action: respects the sendBehavior setting.
// When "interrupt" (default), primary action sends immediately (interrupts).
// When "queue", primary action queues when agent is running.
const handleDefaultSendAction = useCallback(() => {
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
handleQueueMessage();
} else {
handleSendMessage();
}
}, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]);
// Alternate send action: always the opposite of the default.
const handleAlternateSendAction = useCallback(() => {
if (defaultSendBehavior === "queue") {
handleSendMessage(); // interrupt
} else if (onQueue) {
handleQueueMessage(); // queue
}
}, [defaultSendBehavior, handleSendMessage, handleQueueMessage, onQueue]);
// Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === "object" && v !== null && "scrollHeight" in v;
@@ -902,18 +872,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// Shift+Enter: add newline (default behavior, don't intercept)
if (shiftKey) return;
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): alternate action
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault();
handleAlternateSendAction();
handleQueueMessage();
return;
}
// Enter: default send action (interrupt or queue, based on setting)
// Enter: send (interrupts agent if running)
if (isSubmitDisabled || isSubmitLoading || disabled) return;
event.preventDefault();
handleDefaultSendAction();
handleSendMessage();
}
const hasImages = images.length > 0;
@@ -922,14 +892,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: defaultActionQueues
? "Queue message"
: isAgentRunning
? "Send and interrupt"
: "Send message";
: isAgentRunning
? "Send and interrupt"
: "Send message";
const handleInputChange = useCallback(
(nextValue: string) => {
@@ -1104,10 +1071,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent>
</Tooltip>
{rightContent}
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
{hasSendableContent && isAgentRunning && onQueue && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleAlternateSendAction}
onPress={handleQueueMessage}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
@@ -1132,7 +1099,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleDefaultSendAction}
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
@@ -1146,7 +1113,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
<Text style={styles.tooltipText}>Send</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View>
</TooltipContent>

View File

@@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
field: {
marginTop: theme.spacing[3],
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
},
actions: {
flexDirection: "row",
gap: theme.spacing[3],
marginTop: theme.spacing[4],
},
}));
export interface NameHostModalProps {
visible: boolean;
serverId: string;
hostname: string | null;
onSkip: () => void;
onSave: (label: string) => void;
}
export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: NameHostModalProps) {
const { theme } = useUnistyles();
const [label, setLabel] = useState("");
const hasEditedRef = useRef(false);
const suggested = (hostname?.trim() || serverId).trim();
useEffect(() => {
if (!visible) return;
setLabel(suggested);
hasEditedRef.current = false;
}, [suggested, visible]);
useEffect(() => {
if (!visible) return;
if (hasEditedRef.current) return;
if (!hostname) return;
const trimmed = label.trim();
if (trimmed.length === 0 || trimmed === serverId) {
setLabel(hostname.trim());
}
}, [hostname, label, serverId, visible]);
const handleChange = useCallback((value: string) => {
hasEditedRef.current = true;
setLabel(value);
}, []);
const handleSave = useCallback(() => {
const trimmed = label.trim();
if (!trimmed) {
onSkip();
return;
}
onSave(trimmed);
}, [label, onSave, onSkip]);
return (
<AdaptiveModalSheet
title="Name this host"
visible={visible}
onClose={onSkip}
testID="name-host-modal"
>
<Text style={styles.helper}>Optional. You can rename this later in Settings.</Text>
<View style={styles.field}>
<Text style={styles.label}>Label</Text>
<AdaptiveTextInput
value={label}
onChangeText={handleChange}
placeholder={suggested}
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
onSubmitEditing={handleSave}
/>
</View>
<View style={styles.actions}>
<Button style={{ flex: 1 }} variant="secondary" onPress={onSkip} testID="name-host-skip">
Skip
</Button>
<Button style={{ flex: 1 }} variant="default" onPress={handleSave} testID="name-host-save">
Save
</Button>
</View>
</AdaptiveModalSheet>
);
}

View File

@@ -146,7 +146,7 @@ export function PairLinkModal({
await client.close().catch(() => undefined);
const isNewHost = !daemons.some((daemon) => daemon.serverId === parsedOffer.serverId);
const profile = await upsertDaemonFromOfferUrl(raw, hostname ?? undefined);
const profile = await upsertDaemonFromOfferUrl(raw);
onSaved?.({ profile, serverId: parsedOffer.serverId, hostname, isNewHost });
handleClose();
} catch (error) {

View File

@@ -8,11 +8,14 @@ import type { HostProfile } from "@/types/host-connection";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
useHostMutations,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { AddHostModal } from "./add-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { NameHostModal } from "./name-host-modal";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
import { buildHostRootRoute } from "@/utils/host-routes";
@@ -234,19 +237,41 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
const { renameHost } = useHostMutations();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const [pendingRedirectServerId, setPendingRedirectServerId] = useState<string | null>(null);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
useEffect(() => {
if (!anyOnlineServerId) {
return;
}
if (pendingNameHost) {
return;
}
router.replace(buildHostRootRoute(anyOnlineServerId));
}, [anyOnlineServerId, router]);
}, [anyOnlineServerId, pendingNameHost, router]);
const finishOnboarding = useCallback(
(serverId: string) => {
@@ -371,8 +396,13 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId }) => {
onSaved={({ profile, serverId, hostname, isNewHost }) => {
onHostAdded?.(profile);
setPendingRedirectServerId(serverId);
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
return;
}
finishOnboarding(serverId);
}}
/>
@@ -380,11 +410,38 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId }) => {
onSaved={({ profile, serverId, hostname, isNewHost }) => {
onHostAdded?.(profile);
setPendingRedirectServerId(serverId);
if (isNewHost) {
setPendingNameHost({ serverId, hostname });
return;
}
finishOnboarding(serverId);
}}
/>
{pendingNameHost && pendingRedirectServerId ? (
<NameHostModal
visible
serverId={pendingNameHost.serverId}
hostname={pendingNameHostname}
onSkip={() => {
const serverId = pendingRedirectServerId;
setPendingNameHost(null);
setPendingRedirectServerId(null);
finishOnboarding(serverId);
}}
onSave={(label) => {
const serverId = pendingRedirectServerId;
void renameHost(pendingNameHost.serverId, label).finally(() => {
setPendingNameHost(null);
setPendingRedirectServerId(null);
finishOnboarding(serverId);
});
}}
/>
) : null}
</ScrollView>
);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Alert, Text, View } from "react-native";
import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import {
@@ -20,12 +21,15 @@ import { openExternalUrl } from "@/utils/open-external-url";
import { isVersionMismatch } from "@/desktop/updates/desktop-updates";
import {
getCliDaemonStatus,
getDesktopDaemonLogs,
getDesktopDaemonStatus,
restartDesktopDaemon,
shouldUseDesktopDaemon,
startDesktopDaemon,
stopDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
export interface LocalDaemonSectionProps {
appVersion: string | null;
@@ -36,18 +40,44 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
const { theme } = useUnistyles();
const showSection = shouldUseDesktopDaemon();
const { settings, updateSettings } = useAppSettings();
const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus();
const [daemonStatus, setDaemonStatus] = useState<DesktopDaemonStatus | null>(null);
const [daemonVersion, setDaemonVersion] = useState<string | null>(null);
const [statusError, setStatusError] = useState<string | null>(null);
const [isRestartingDaemon, setIsRestartingDaemon] = useState(false);
const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [daemonLogs, setDaemonLogs] = useState<DesktopDaemonLogs | null>(null);
const [isLogsModalOpen, setIsLogsModalOpen] = useState(false);
const [cliStatusOutput, setCliStatusOutput] = useState<string | null>(null);
const [isCliStatusModalOpen, setIsCliStatusModalOpen] = useState(false);
const [isLoadingCliStatus, setIsLoadingCliStatus] = useState(false);
const daemonStatus = data?.status ?? null;
const daemonLogs = data?.logs ?? null;
const daemonVersion = daemonStatus?.version ?? null;
const loadDaemonData = useCallback(() => {
if (!showSection) {
return Promise.resolve();
}
return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()])
.then(([status, logs]) => {
setDaemonStatus(status);
setDaemonLogs(logs);
setDaemonVersion(status.version);
setStatusError(null);
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setStatusError(message);
});
}, [showSection]);
useFocusEffect(
useCallback(() => {
if (!showSection) {
return undefined;
}
void loadDaemonData();
return undefined;
}, [loadDaemonData, showSection]),
);
const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion);
const daemonStatusStateText =
@@ -86,12 +116,12 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon;
void action()
.then((newStatus) => {
setStatus(newStatus);
.then((status) => {
setDaemonStatus(status);
setStatusMessage(
daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.",
);
refetch();
return loadDaemonData();
})
.catch((error) => {
console.error("[Settings] Failed to change desktop daemon state", error);
@@ -106,7 +136,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
console.error("[Settings] Failed to open desktop daemon action confirmation", error);
Alert.alert("Error", "Unable to open the daemon confirmation dialog.");
});
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, refetch, setStatus, showSection]);
}, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]);
const handleToggleDaemonManagement = useCallback(() => {
if (isUpdatingDaemonManagement) {
@@ -152,14 +182,9 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
: Promise.resolve(daemonStatus ?? null);
void stopPromise
.then((newStatus) => {
if (newStatus) {
setStatus(newStatus);
}
return updateSettings({ manageBuiltInDaemon: false });
})
.then(() => updateSettings({ manageBuiltInDaemon: false }))
.then(() => loadDaemonData())
.then(() => {
refetch();
setStatusMessage("Built-in daemon paused and stopped.");
})
.catch((error) => {
@@ -177,8 +202,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
}, [
daemonStatus,
isUpdatingDaemonManagement,
refetch,
setStatus,
loadDaemonData,
settings.manageBuiltInDaemon,
updateSettings,
]);
@@ -253,138 +277,126 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD
Advanced settings
</Button>
</View>
{isLoading ? (
<View style={[settingsStyles.card, styles.loadingCard]}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>Only the built-in desktop daemon is shown here.</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
) : (
<>
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Status</Text>
<Text style={settingsStyles.rowHint}>
Only the built-in desktop daemon is shown here.
</Text>
</View>
<View style={styles.statusValueGroup}>
<Text style={styles.valueText}>{daemonStatusStateText}</Text>
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
</View>
</View>
{showLifecycleControls ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
</>
) : null}
{showLifecycleControls ? (
<>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowTitle}>Daemon management</Text>
<Text style={settingsStyles.rowHint}>
{daemonLogs?.logPath ?? "Log path unavailable."}
</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
{isDaemonManagementPaused
? "Paused. The built-in daemon stays stopped until you start it again."
: "Enabled. Paseo can manage the built-in daemon from the desktop app."}
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
leftIcon={
isDaemonManagementPaused ? (
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
) : (
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
)
}
onPress={handleToggleDaemonManagement}
disabled={isUpdatingDaemonManagement}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
{isUpdatingDaemonManagement
? isDaemonManagementPaused
? "Resuming..."
: "Pausing..."
: isDaemonManagementPaused
? "Resume"
: "Pause"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
App and daemon versions don't match. Update both to the same version for the best
experience.
</Text>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{daemonActionLabel}</Text>
<Text style={settingsStyles.rowHint}>{daemonActionMessage}</Text>
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
</View>
<Button
variant="outline"
size="sm"
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleUpdateLocalDaemon}
disabled={isRestartingDaemon}
>
{isRestartingDaemon
? daemonStatus?.status === "running"
? "Restarting..."
: "Starting..."
: daemonActionLabel}
</Button>
</View>
) : null}
</>
)}
</>
) : null}
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Log file</Text>
<Text style={settingsStyles.rowHint}>{daemonLogs?.logPath ?? "Log path unavailable."}</Text>
</View>
<View style={styles.actionGroup}>
{daemonLogs?.logPath ? (
<Button
variant="outline"
size="sm"
leftIcon={<Copy size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleCopyLogPath}
>
Copy path
</Button>
) : null}
<Button
variant="outline"
size="sm"
leftIcon={<FileText size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={handleOpenLogs}
disabled={!daemonLogs}
>
Open logs
</Button>
</View>
</View>
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Full status</Text>
<Text style={settingsStyles.rowHint}>
Runs `paseo daemon status` and shows the output.
</Text>
</View>
<Button
variant="outline"
size="sm"
leftIcon={<Activity size={theme.iconSize.sm} color={theme.colors.foreground} />}
onPress={() => void handleOpenCliStatus()}
disabled={isLoadingCliStatus}
>
{isLoadingCliStatus ? "Loading..." : "View status"}
</Button>
</View>
</View>
{daemonVersionMismatch ? (
<View style={styles.warningCard}>
<Text style={styles.warningText}>
App and daemon versions don't match. Update both to the same version for the best
experience.
</Text>
</View>
) : null}
<AdaptiveModalSheet
visible={isLogsModalOpen}
@@ -435,11 +447,6 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
justifyContent: "flex-end",
},
loadingCard: {
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[6],
},
statusValueGroup: {
alignItems: "flex-end",
gap: 2,

View File

@@ -1,53 +0,0 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getDesktopDaemonLogs,
getDesktopDaemonStatus,
shouldUseDesktopDaemon,
type DesktopDaemonLogs,
type DesktopDaemonStatus,
} from "@/desktop/daemon/desktop-daemon";
const DAEMON_STATUS_QUERY_KEY = ["desktopDaemonStatus"] as const;
interface DaemonStatusData {
status: DesktopDaemonStatus;
logs: DesktopDaemonLogs;
}
export function useDaemonStatus() {
const queryClient = useQueryClient();
const enabled = shouldUseDesktopDaemon();
const query = useQuery<DaemonStatusData>({
queryKey: DAEMON_STATUS_QUERY_KEY,
enabled,
staleTime: 30_000,
refetchOnMount: "always",
queryFn: async () => {
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
return { status, logs };
},
});
const setStatus = useCallback(
(status: DesktopDaemonStatus) => {
queryClient.setQueryData<DaemonStatusData>(DAEMON_STATUS_QUERY_KEY, (prev) =>
prev ? { ...prev, status } : undefined,
);
},
[queryClient],
);
const refetch = useCallback(() => {
void queryClient.invalidateQueries({ queryKey: DAEMON_STATUS_QUERY_KEY });
}, [queryClient]);
return {
data: query.data ?? null,
isLoading: query.isLoading,
error: query.error instanceof Error ? query.error.message : null,
setStatus,
refetch,
};
}

View File

@@ -6,18 +6,14 @@ export const APP_SETTINGS_KEY = "@paseo:app-settings";
const LEGACY_SETTINGS_KEY = "@paseo:settings";
const APP_SETTINGS_QUERY_KEY = ["app-settings"];
export type SendBehavior = "interrupt" | "queue";
export interface AppSettings {
theme: "dark" | "light" | "auto";
manageBuiltInDaemon: boolean;
sendBehavior: SendBehavior;
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
theme: "auto",
manageBuiltInDaemon: true,
sendBehavior: "interrupt",
};
export interface UseAppSettingsReturn {

View File

@@ -5,7 +5,6 @@ import type {
FetchAgentsEntry,
FetchAgentsOptions,
} from "@server/client/daemon-client";
import type { ConnectionOffer } from "@server/shared/connection-offer";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
@@ -190,17 +189,6 @@ function makeHost(input?: Partial<HostProfile>): HostProfile {
};
}
function makeOffer(input?: Partial<ConnectionOffer>): ConnectionOffer {
return {
v: 2,
serverId: input?.serverId ?? "srv_offer",
daemonPublicKeyB64: input?.daemonPublicKeyB64 ?? "pk_test_offer",
relay: {
endpoint: input?.relay?.endpoint ?? "relay.paseo.sh:443",
},
};
}
function makeDeps(
latencyByConnectionId: Record<string, number | Error>,
createdClients: FakeDaemonClient[],
@@ -1307,53 +1295,4 @@ describe("HostRuntimeStore", () => {
store.syncHosts([]);
});
it("uses the advertised hostname when adding a relay host from a pairing offer", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("mbp");
store.syncHosts([]);
});
it("keeps a custom host label when re-pairing with an advertised hostname", async () => {
const store = new HostRuntimeStore({
deps: {
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
connectToDaemon: async ({ host }) => ({
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
serverId: host.serverId,
hostname: host.label ?? null,
}),
getClientId: async () => "cid_test_runtime",
},
});
await store.upsertRelayConnection({
serverId: "srv_offer",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "pk_test_offer",
label: "Custom name",
});
await store.upsertConnectionFromOffer(makeOffer(), "mbp");
const pairedHost = store.getHosts().find((host) => host.serverId === "srv_offer");
expect(pairedHost?.label).toBe("Custom name");
store.syncHosts([]);
});
});

View File

@@ -1287,22 +1287,15 @@ export class HostRuntimeStore {
});
}
async upsertConnectionFromOffer(
offer: ConnectionOffer,
label?: string,
): Promise<HostProfile> {
async upsertConnectionFromOffer(offer: ConnectionOffer): Promise<HostProfile> {
return this.upsertRelayConnection({
serverId: offer.serverId,
relayEndpoint: offer.relay.endpoint,
daemonPublicKeyB64: offer.daemonPublicKeyB64,
label,
});
}
async upsertConnectionFromOfferUrl(
offerUrlOrFragment: string,
label?: string,
): Promise<HostProfile> {
async upsertConnectionFromOfferUrl(offerUrlOrFragment: string): Promise<HostProfile> {
const marker = "#offer=";
const idx = offerUrlOrFragment.indexOf(marker);
if (idx === -1) {
@@ -1314,7 +1307,7 @@ export class HostRuntimeStore {
}
const payload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(payload);
return this.upsertConnectionFromOffer(offer, label);
return this.upsertConnectionFromOffer(offer);
}
async addConnectionFromListenAndWaitForOnline(input: {
@@ -1963,11 +1956,8 @@ export interface HostMutations {
daemonPublicKeyB64: string;
label?: string;
}) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer, label?: string) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: (
offerUrlOrFragment: string,
label?: string,
) => Promise<HostProfile>;
upsertConnectionFromOffer: (offer: ConnectionOffer) => Promise<HostProfile>;
upsertConnectionFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
renameHost: (serverId: string, label: string) => Promise<void>;
removeHost: (serverId: string) => Promise<void>;
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
@@ -1979,8 +1969,8 @@ export function useHostMutations(): HostMutations {
() => ({
upsertDirectConnection: (input) => store.upsertDirectConnection(input),
upsertRelayConnection: (input) => store.upsertRelayConnection(input),
upsertConnectionFromOffer: (offer, label) => store.upsertConnectionFromOffer(offer, label),
upsertConnectionFromOfferUrl: (url, label) => store.upsertConnectionFromOfferUrl(url, label),
upsertConnectionFromOffer: (offer) => store.upsertConnectionFromOffer(offer),
upsertConnectionFromOfferUrl: (url) => store.upsertConnectionFromOfferUrl(url),
renameHost: (serverId, label) => store.renameHost(serverId, label),
removeHost: (serverId) => store.removeHost(serverId),
removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId),

View File

@@ -15,6 +15,7 @@ import {
RotateCw,
Trash2,
Server,
Palette,
Keyboard,
Stethoscope,
Info,
@@ -23,7 +24,7 @@ import {
Blocks,
Smartphone,
} from "lucide-react-native";
import { useAppSettings, type AppSettings, type SendBehavior } from "@/hooks/use-settings";
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
import type { HostProfile, HostConnection } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons";
@@ -41,6 +42,7 @@ import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { NameHostModal } from "@/components/name-host-modal";
import { Button } from "@/components/ui/button";
import { SegmentedControl } from "@/components/ui/segmented-control";
import {
@@ -75,7 +77,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
type SettingsSectionId =
| "hosts"
| "general"
| "appearance"
| "shortcuts"
| "integrations"
| "providers"
@@ -94,21 +96,21 @@ interface SettingsSectionDef {
function getSettingsSections(context: { isDesktopApp: boolean }): SettingsSectionDef[] {
const sections: SettingsSectionDef[] = [
{ id: "hosts", label: "Hosts", icon: Server },
{ id: "general", label: "General", icon: Settings },
{ id: "appearance", label: "Appearance", icon: Palette },
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "permissions", label: "Permissions", icon: Shield },
];
if (context.isDesktopApp) {
sections.push(
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
{ id: "integrations", label: "Integrations", icon: Puzzle },
{ id: "pair-device", label: "Pair device", icon: Smartphone },
{ id: "daemon", label: "Daemon", icon: Settings },
{ id: "providers", label: "Providers", icon: Blocks },
);
}
sections.push(
{ id: "providers", label: "Providers", icon: Blocks },
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
{ id: "about", label: "About", icon: Info },
);
@@ -202,6 +204,10 @@ interface HostsSectionProps {
goBackToAddConnectionMethods: () => void;
setIsDirectHostVisible: (visible: boolean) => void;
setIsPasteLinkVisible: (visible: boolean) => void;
pendingNameHost: { serverId: string; hostname: string | null } | null;
setPendingNameHost: (host: { serverId: string; hostname: string | null } | null) => void;
pendingNameHostname: string | null;
renameHost: (serverId: string, label: string) => Promise<void>;
pendingRemoveHost: HostProfile | null;
setPendingRemoveHost: (host: HostProfile | null) => void;
isRemovingHost: boolean;
@@ -284,14 +290,38 @@ function HostsSection(props: HostsSectionProps) {
visible={props.isDirectHostVisible}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
<PairLinkModal
visible={props.isPasteLinkVisible}
onClose={props.closeAddConnectionFlow}
onCancel={props.goBackToAddConnectionMethods}
onSaved={({ serverId, hostname, isNewHost }) => {
if (isNewHost) {
props.setPendingNameHost({ serverId, hostname });
}
}}
/>
{props.pendingNameHost ? (
<NameHostModal
visible
serverId={props.pendingNameHost.serverId}
hostname={props.pendingNameHostname}
onSkip={() => props.setPendingNameHost(null)}
onSave={(label) => {
void props.renameHost(props.pendingNameHost!.serverId, label).finally(() => {
props.setPendingNameHost(null);
});
}}
/>
) : null}
{props.pendingRemoveHost ? (
<AdaptiveModalSheet
title="Remove host"
@@ -356,20 +386,15 @@ function HostsSection(props: HostsSectionProps) {
);
}
interface GeneralSectionProps {
interface AppearanceSectionProps {
settings: AppSettings;
handleThemeChange: (theme: AppSettings["theme"]) => void;
handleSendBehaviorChange: (behavior: SendBehavior) => void;
}
function GeneralSection({
settings,
handleThemeChange,
handleSendBehaviorChange,
}: GeneralSectionProps) {
function AppearanceSection({ settings, handleThemeChange }: AppearanceSectionProps) {
return (
<View style={settingsStyles.section}>
<Text style={settingsStyles.sectionTitle}>General</Text>
<Text style={settingsStyles.sectionTitle}>Appearance</Text>
<View style={[settingsStyles.card, styles.audioCard]}>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
@@ -399,23 +424,6 @@ function GeneralSection({
]}
/>
</View>
<View style={styles.audioRow}>
<View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Default send</Text>
<Text style={styles.audioRowSubtitle}>
What happens when you press Enter while the agent is running
</Text>
</View>
<SegmentedControl
size="sm"
value={settings.sendBehavior}
onValueChange={handleSendBehaviorChange}
options={[
{ value: "interrupt", label: "Interrupt" },
{ value: "queue", label: "Queue" },
]}
/>
</View>
</View>
</View>
);
@@ -606,7 +614,7 @@ function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) {
interface SettingsSectionContentProps {
sectionId: SettingsSectionId;
hostsProps: HostsSectionProps;
generalProps: GeneralSectionProps;
appearanceProps: AppearanceSectionProps;
providersProps: ProvidersSectionProps;
diagnosticsProps: DiagnosticsSectionProps;
aboutProps: AboutSectionProps;
@@ -618,7 +626,7 @@ interface SettingsSectionContentProps {
function SettingsSectionContent({
sectionId,
hostsProps,
generalProps,
appearanceProps,
providersProps,
diagnosticsProps,
aboutProps,
@@ -629,8 +637,8 @@ function SettingsSectionContent({
switch (sectionId) {
case "hosts":
return <HostsSection {...hostsProps} />;
case "general":
return <GeneralSection {...generalProps} />;
case "appearance":
return <AppearanceSection {...appearanceProps} />;
case "shortcuts":
return <KeyboardShortcutsSection />;
case "providers":
@@ -855,6 +863,10 @@ export default function SettingsScreen() {
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [pendingNameHost, setPendingNameHost] = useState<{
serverId: string;
hostname: string | null;
} | null>(null);
const [pendingRemoveHost, setPendingRemoveHost] = useState<HostProfile | null>(null);
const [isRemovingHost, setIsRemovingHost] = useState(false);
const [editingDaemon, setEditingDaemon] = useState<HostProfile | null>(null);
@@ -872,6 +884,19 @@ export default function SettingsScreen() {
const editingDaemonLive = editingServerId
? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null)
: null;
const pendingNameHostname = useSessionStore(
useCallback(
(state) => {
if (!pendingNameHost) return null;
return (
state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ??
pendingNameHost.hostname ??
null
);
},
[pendingNameHost],
),
);
useEffect(() => {
return () => {
@@ -978,13 +1003,6 @@ export default function SettingsScreen() {
[updateSettings],
);
const handleSendBehaviorChange = useCallback(
(behavior: SendBehavior) => {
void updateSettings({ sendBehavior: behavior });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) {
return;
@@ -1031,6 +1049,10 @@ export default function SettingsScreen() {
goBackToAddConnectionMethods,
setIsDirectHostVisible,
setIsPasteLinkVisible,
pendingNameHost,
setPendingNameHost,
pendingNameHostname,
renameHost,
pendingRemoveHost,
setPendingRemoveHost,
isRemovingHost,
@@ -1047,10 +1069,9 @@ export default function SettingsScreen() {
isMountedRef,
};
const generalProps: GeneralSectionProps = {
const appearanceProps: AppearanceSectionProps = {
settings,
handleThemeChange,
handleSendBehaviorChange,
};
const diagnosticsProps: DiagnosticsSectionProps = {
@@ -1071,7 +1092,7 @@ export default function SettingsScreen() {
const sectionContentProps: Omit<SettingsSectionContentProps, "sectionId"> = {
hostsProps,
generalProps,
appearanceProps,
providersProps,
diagnosticsProps,
aboutProps,
@@ -1880,11 +1901,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
audioRowSubtitle: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
providerActions: {
flexDirection: "row",
alignItems: "center",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.51",
"@getpaseo/server": "0.1.51",
"@getpaseo/relay": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
@@ -12,8 +12,8 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@getpaseo/cli": "0.1.51",
"@getpaseo/server": "0.1.51",
"@getpaseo/cli": "0.1.51-rc.1",
"@getpaseo/server": "0.1.51-rc.1",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
"ws": "^8.14.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"description": "Native module for two way audio streaming",
"main": "build/index.js",
"types": "build/index.d.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"type": "module",
"publishConfig": {
"access": "public"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -64,8 +64,8 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/highlight": "0.1.51",
"@getpaseo/relay": "0.1.51",
"@getpaseo/highlight": "0.1.51-rc.1",
"@getpaseo/relay": "0.1.51-rc.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.2.6",

View File

@@ -415,39 +415,10 @@ describe("transformPiModels", () => {
});
describe("ACPAgentSession slash commands", () => {
test("returns immediately for ACP sessions that do not wait for async command discovery", async () => {
test("caches ACP available commands for listCommands", async () => {
const session = createSession();
await expect(session.listCommands()).resolves.toEqual([]);
});
test("waits for async available_commands_update when enabled", async () => {
const session = new ACPAgentSession(
{
provider: "pi",
cwd: "/tmp/paseo-acp-test",
},
{
provider: "pi",
logger: createTestLogger(),
defaultCommand: ["pi-acp"],
defaultModes: [],
modelTransformer: transformPiModels,
sessionResponseTransformer: transformPiSessionResponse,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
},
);
const listCommandsPromise = session.listCommands();
expect(await session.listCommands()).toEqual([]);
(session as any).translateSessionUpdate({
sessionUpdate: "available_commands_update",
@@ -463,19 +434,6 @@ describe("ACPAgentSession slash commands", () => {
],
});
expect(await listCommandsPromise).toEqual([
{
name: "research_codebase",
description: "Search the workspace for relevant files",
argumentHint: "",
},
{
name: "create_plan",
description: "Draft a plan for the requested work",
argumentHint: "",
},
]);
expect(await session.listCommands()).toEqual([
{
name: "research_codebase",

View File

@@ -125,8 +125,6 @@ type ACPAgentClientOptions = {
thinkingOptionId: string,
) => Promise<void>;
capabilities?: AgentCapabilityFlags;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
};
type ACPAgentSessionOptions = {
@@ -146,8 +144,6 @@ type ACPAgentSessionOptions = {
capabilities: AgentCapabilityFlags;
handle?: AgentPersistenceHandle;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
};
type SpawnedACPProcess = {
@@ -306,8 +302,6 @@ export class ACPAgentClient implements AgentClient {
sessionId: string,
thinkingOptionId: string,
) => Promise<void>;
private readonly waitForInitialCommands: boolean;
private readonly initialCommandsWaitTimeoutMs: number;
constructor(options: ACPAgentClientOptions) {
this.provider = options.provider;
@@ -320,8 +314,6 @@ export class ACPAgentClient implements AgentClient {
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.toolSnapshotTransformer = options.toolSnapshotTransformer;
this.thinkingOptionWriter = options.thinkingOptionWriter;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
}
async createSession(
@@ -343,8 +335,6 @@ export class ACPAgentClient implements AgentClient {
thinkingOptionWriter: this.thinkingOptionWriter,
capabilities: this.capabilities,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
},
);
await session.initializeNewSession();
@@ -385,8 +375,6 @@ export class ACPAgentClient implements AgentClient {
capabilities: this.capabilities,
handle,
launchEnv: launchContext?.env,
waitForInitialCommands: this.waitForInitialCommands,
initialCommandsWaitTimeoutMs: this.initialCommandsWaitTimeoutMs,
});
await session.initializeResumedSession();
return session;
@@ -629,10 +617,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private cachedCommands: AgentSlashCommand[] = [];
private commandsReadyDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
private commandsReadySettled = false;
private waitForInitialCommands: boolean;
private initialCommandsWaitTimeoutMs: number;
private currentTurnUsage: AgentUsage | undefined;
private activeForegroundTurnId: string | null = null;
private closed = false;
@@ -661,8 +645,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentModel = config.model ?? null;
this.thinkingOptionId = config.thinkingOptionId ?? null;
this.currentTitle = config.title ?? null;
this.waitForInitialCommands = options.waitForInitialCommands ?? false;
this.initialCommandsWaitTimeoutMs = options.initialCommandsWaitTimeoutMs ?? 1500;
}
get id(): string | null {
@@ -894,59 +876,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode;
}
private ensureCommandsReadyDeferred(): void {
if (this.commandsReadyDeferred || this.commandsReadySettled || this.cachedCommands.length > 0) {
return;
}
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
this.commandsReadyDeferred = { promise, resolve };
}
private settleCommandsReady(): void {
if (this.commandsReadySettled) {
return;
}
this.commandsReadySettled = true;
this.commandsReadyDeferred?.resolve();
this.commandsReadyDeferred = null;
}
private async waitForCommandsReady(): Promise<void> {
const deferred = this.commandsReadyDeferred;
if (!deferred) {
return;
}
let timer: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
deferred.promise,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, this.initialCommandsWaitTimeoutMs);
}),
]);
} finally {
if (timer) {
clearTimeout(timer);
}
}
}
async listCommands(): Promise<AgentSlashCommand[]> {
if (this.cachedCommands.length > 0) {
return this.cachedCommands;
}
if (!this.waitForInitialCommands || this.closed) {
return this.cachedCommands;
}
this.ensureCommandsReadyDeferred();
await this.waitForCommandsReady();
this.settleCommandsReady();
return this.cachedCommands;
}
@@ -1116,8 +1046,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
this.closed = true;
this.settleCommandsReady();
for (const pending of this.pendingPermissions.values()) {
pending.resolve({ outcome: { outcome: "cancelled" } });
}
@@ -1464,7 +1392,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
description: command.description,
argumentHint: "",
}));
this.settleCommandsReady();
return [];
default:
return [];

View File

@@ -315,23 +315,6 @@ const hasOpenCode = isBinaryInstalled("opencode");
});
describe("OpenCode adapter context-window normalization", () => {
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
{ type: "text", text: "Describe this image." },
{ type: "image", mimeType: "image/png", data: "YWJjMTIz" },
]),
).toEqual([
{ type: "text", text: "Describe this image." },
{
type: "file",
mime: "image/png",
filename: "attachment-1.png",
url: "data:image/png;base64,YWJjMTIz",
},
]);
});
test("preserves provider catalog context limit in model metadata", () => {
const definition = __openCodeInternals.buildOpenCodeModelDefinition(
{ id: "openai", name: "OpenAI" },

View File

@@ -4,10 +4,8 @@ import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput,
type OpencodeClient,
type Part as OpenCodePart,
type TextPartInput as OpenCodeTextPartInput,
} from "@opencode-ai/sdk/v2/client";
import net from "node:net";
import type { Logger } from "pino";
@@ -496,66 +494,7 @@ function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
].some((value) => typeof value === "number" && Number.isFinite(value));
}
function getOpenCodeAttachmentExtension(mimeType: string): string {
switch (mimeType) {
case "image/png":
return "png";
case "image/jpeg":
return "jpg";
case "image/webp":
return "webp";
case "image/gif":
return "gif";
case "image/svg+xml":
return "svg";
default:
return "bin";
}
}
function toOpenCodeDataUrl(mimeType: string, data: string): { mimeType: string; url: string } {
const match = data.match(/^data:([^;,]+);base64,(.+)$/);
if (match) {
return {
mimeType: match[1] ?? mimeType,
url: data,
};
}
return {
mimeType,
url: `data:${mimeType};base64,${data}`,
};
}
function buildOpenCodePromptParts(
prompt: AgentPromptInput,
): Array<OpenCodeTextPartInput | OpenCodeFilePartInput> {
if (typeof prompt === "string") {
return [{ type: "text", text: prompt }];
}
let attachmentOrdinal = 0;
const output: Array<OpenCodeTextPartInput | OpenCodeFilePartInput> = [];
for (const part of prompt) {
if (part.type === "text") {
output.push({ type: "text", text: part.text });
continue;
}
attachmentOrdinal += 1;
const normalized = toOpenCodeDataUrl(part.mimeType, part.data);
output.push({
type: "file",
mime: normalized.mimeType,
filename: `attachment-${attachmentOrdinal}.${getOpenCodeAttachmentExtension(
normalized.mimeType,
)}`,
url: normalized.url,
});
}
return output;
}
export const __openCodeInternals = {
buildOpenCodePromptParts,
buildOpenCodeModelContextWindowLookup,
buildOpenCodeModelDefinition,
buildOpenCodeModelLookupKey,
@@ -1497,7 +1436,7 @@ class OpenCodeAgentSession implements AgentSession {
this.accumulatedUsage =
contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = buildOpenCodePromptParts(prompt);
const parts = this.buildPromptParts(prompt);
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant = thinkingOptionId ?? undefined;
@@ -1923,6 +1862,15 @@ class OpenCodeAgentSession implements AgentSession {
this.activeForegroundTurnId = null;
}
private buildPromptParts(prompt: AgentPromptInput): Array<{ type: "text"; text: string }> {
if (typeof prompt === "string") {
return [{ type: "text", text: prompt }];
}
return prompt
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => ({ type: "text", text: p.text }));
}
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
const trimmed = text.trim();
if (!trimmed.startsWith("/") || trimmed.length <= 1) {

View File

@@ -298,8 +298,6 @@ export class PiACPAgentClient extends ACPAgentClient {
await connection.setSessionMode({ sessionId, modeId: thinkingOptionId });
},
capabilities: PI_CAPABILITIES,
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: 1500,
});
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.51",
"version": "0.1.51-rc.1",
"private": true,
"type": "module",
"scripts": {