diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 000000000..86fb7306a --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,428 @@ +# Data Model + +Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility. + +All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`). + +--- + +## Directory layout + +``` +$PASEO_HOME/ +├── config.json # Daemon configuration +├── agents/ +│ └── {project-dir}/ +│ └── {agentId}.json # One file per agent +├── schedules/ +│ └── {scheduleId}.json # One file per schedule +├── chat/ +│ └── rooms.json # All rooms + messages +├── loops/ +│ └── loops.json # All loop records +├── projects/ +│ ├── projects.json # Project registry +│ └── workspaces.json # Workspace registry +└── push-tokens.json # Expo push notification tokens +``` + +--- + +## 1. Agent Record + +**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json` + +Each agent is stored as a separate JSON file, grouped by project directory. + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | UUID, primary key | +| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | +| `cwd` | `string` | Working directory the agent operates in | +| `createdAt` | `string` (ISO 8601) | Creation timestamp | +| `updatedAt` | `string` (ISO 8601) | Last update timestamp | +| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | +| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | +| `title` | `string?` | User-visible title | +| `labels` | `Record` | Key-value labels (default `{}`) | +| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | +| `lastModeId` | `string?` | Last active mode ID | +| `config` | `SerializableConfig?` | Agent session configuration (see below) | +| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | +| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | +| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | +| `requiresAttention` | `boolean?` | Whether the agent needs user attention | +| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | +| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | +| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | + +### Nested: SerializableConfig + +| Field | Type | Description | +|---|---|---| +| `title` | `string?` | Configured title | +| `modeId` | `string?` | Configured mode | +| `model` | `string?` | Configured model | +| `thinkingOptionId` | `string?` | Thinking/reasoning level | +| `featureValues` | `Record?` | Feature preference overrides | +| `extra` | `Record?` | Provider-specific config | +| `systemPrompt` | `string?` | Custom system prompt | +| `mcpServers` | `Record?` | MCP server configurations | + +### Nested: RuntimeInfo + +| Field | Type | Description | +|---|---|---| +| `provider` | `string` | Active provider | +| `sessionId` | `string?` | Active session ID | +| `model` | `string?` | Active model | +| `thinkingOptionId` | `string?` | Active thinking option | +| `modeId` | `string?` | Active mode | +| `extra` | `Record?` | Provider-specific runtime data | + +### Nested: PersistenceHandle + +| Field | Type | Description | +|---|---|---| +| `provider` | `string` | Provider that owns the session | +| `sessionId` | `string` | Session ID for resumption | +| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) | +| `metadata` | `Record?` | Extra metadata | + +### Nested: AgentFeature (discriminated union on `type`) + +**Toggle:** + +| Field | Type | +|---|---| +| `type` | `"toggle"` | +| `id` | `string` | +| `label` | `string` | +| `description` | `string?` | +| `tooltip` | `string?` | +| `icon` | `string?` | +| `value` | `boolean` | + +**Select:** + +| Field | Type | +|---|---| +| `type` | `"select"` | +| `id` | `string` | +| `label` | `string` | +| `description` | `string?` | +| `tooltip` | `string?` | +| `icon` | `string?` | +| `value` | `string?` | +| `options` | `AgentSelectOption[]` | + +--- + +## 2. Daemon Configuration + +**Path:** `$PASEO_HOME/config.json` + +Single file, validated with `PersistedConfigSchema`. + +``` +{ + version: 1, + daemon: { + listen: "127.0.0.1:6767", + allowedHosts: true | string[], + mcp: { enabled: boolean }, + cors: { allowedOrigins: string[] }, + relay: { enabled: boolean, endpoint: string, publicEndpoint: string } + }, + app: { + baseUrl: string + }, + providers: { + openai: { apiKey: string }, + local: { modelsDir: string } + }, + agents: { + providers: { + [provider: string]: { + command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] }, + env: Record + } + } + }, + features: { + dictation: { enabled, stt: { provider, model, confidenceThreshold } }, + voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } } + }, + log: { + level, format, + console: { level, format }, + file: { level, path, rotate: { maxSize, maxFiles } } + } +} +``` + +All fields are optional with sensible defaults. + +--- + +## 3. Schedule + +**Path:** `$PASEO_HOME/schedules/{id}.json` + +One file per schedule. ID is 8 hex characters. + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | 8-char hex ID | +| `name` | `string?` | Human-readable name | +| `prompt` | `string` | The prompt to send | +| `cadence` | `ScheduleCadence` | Timing (see below) | +| `target` | `ScheduleTarget` | What to run (see below) | +| `status` | `"active" \| "paused" \| "completed"` | Current state | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution | +| `lastRunAt` | `string?` (ISO 8601) | Last execution time | +| `pausedAt` | `string?` (ISO 8601) | When paused | +| `expiresAt` | `string?` (ISO 8601) | Auto-expire time | +| `maxRuns` | `number?` | Max executions before completing | +| `runs` | `ScheduleRun[]` | Execution history | + +### Nested: ScheduleCadence (discriminated union on `type`) + +- `{ type: "every", everyMs: number }` — interval in milliseconds +- `{ type: "cron", expression: string }` — cron expression + +### Nested: ScheduleTarget (discriminated union on `type`) + +- `{ type: "agent", agentId: string }` — send to existing agent +- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent + +### Nested: ScheduleRun + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Run ID | +| `scheduledFor` | `string` (ISO 8601) | Intended execution time | +| `startedAt` | `string` (ISO 8601) | | +| `endedAt` | `string?` (ISO 8601) | | +| `status` | `"running" \| "succeeded" \| "failed"` | | +| `agentId` | `string?` (UUID) | Agent used for this run | +| `output` | `string?` | Agent output text | +| `error` | `string?` | Error message if failed | + +--- + +## 4. Chat + +**Path:** `$PASEO_HOME/chat/rooms.json` + +Single file containing all rooms and messages. + +```json +{ + "rooms": [ ... ], + "messages": [ ... ] +} +``` + +### ChatRoom + +| Field | Type | Description | +|---|---|---| +| `id` | `string` (UUID) | | +| `name` | `string` | Unique room name (case-insensitive) | +| `purpose` | `string?` | Room description | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | Updated on each new message | + +### ChatMessage + +| Field | Type | Description | +|---|---|---| +| `id` | `string` (UUID) | | +| `roomId` | `string` | FK to ChatRoom.id | +| `authorAgentId` | `string` | Agent ID of the author | +| `body` | `string` | Message text (supports `@mentions`) | +| `replyToMessageId` | `string?` | FK to another ChatMessage.id | +| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs | +| `createdAt` | `string` (ISO 8601) | | + +--- + +## 5. Loop + +**Path:** `$PASEO_HOME/loops/loops.json` + +Single file containing an array of all loop records. + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | 8-char UUID prefix | +| `name` | `string?` | Human-readable name | +| `prompt` | `string` | Worker prompt | +| `cwd` | `string` | Working directory | +| `provider` | `string` | Default provider | +| `model` | `string?` | Default model | +| `workerProvider` | `string?` | Override provider for workers | +| `workerModel` | `string?` | Override model for workers | +| `verifierProvider` | `string?` | Override provider for verifiers | +| `verifierModel` | `string?` | Override model for verifiers | +| `verifyPrompt` | `string?` | LLM verification prompt | +| `verifyChecks` | `string[]` | Shell commands to run as checks | +| `archive` | `boolean` | Whether to archive worker agents after use | +| `sleepMs` | `number` | Delay between iterations (ms) | +| `maxIterations` | `number?` | Cap on iterations | +| `maxTimeMs` | `number?` | Total time budget (ms) | +| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `startedAt` | `string` (ISO 8601) | | +| `completedAt` | `string?` (ISO 8601) | | +| `stopRequestedAt` | `string?` (ISO 8601) | | +| `iterations` | `LoopIteration[]` | | +| `logs` | `LoopLogEntry[]` | | +| `nextLogSeq` | `number` | Monotonic log sequence counter | +| `activeIteration` | `number?` | Currently executing iteration index | +| `activeWorkerAgentId` | `string?` | Currently running worker agent | +| `activeVerifierAgentId` | `string?` | Currently running verifier agent | + +### Nested: LoopIteration + +| Field | Type | Description | +|---|---|---| +| `index` | `number` | 1-based iteration index | +| `workerAgentId` | `string?` | Agent ID of the worker | +| `workerStartedAt` | `string` (ISO 8601) | | +| `workerCompletedAt` | `string?` (ISO 8601) | | +| `verifierAgentId` | `string?` | Agent ID of the verifier | +| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | | +| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | | +| `failureReason` | `string?` | | +| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results | +| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result | + +### Nested: LoopLogEntry + +| Field | Type | +|---|---| +| `seq` | `number` (monotonic) | +| `timestamp` | `string` (ISO 8601) | +| `iteration` | `number?` | +| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` | +| `level` | `"info" \| "error"` | +| `text` | `string` | + +### Nested: LoopVerifyCheckResult + +| Field | Type | +|---|---| +| `command` | `string` | +| `exitCode` | `number` | +| `passed` | `boolean` | +| `stdout` | `string` | +| `stderr` | `string` | +| `startedAt` | `string` (ISO 8601) | +| `completedAt` | `string` (ISO 8601) | + +### Nested: LoopVerifyPromptResult + +| Field | Type | +|---|---| +| `passed` | `boolean` | +| `reason` | `string` | +| `verifierAgentId` | `string?` | +| `startedAt` | `string` (ISO 8601) | +| `completedAt` | `string` (ISO 8601) | + +--- + +## 6. Project Registry + +**Path:** `$PASEO_HOME/projects/projects.json` + +Array of project records. + +| Field | Type | Description | +|---|---|---| +| `projectId` | `string` | Primary key | +| `rootPath` | `string` | Filesystem root of the project | +| `kind` | `"git" \| "non_git"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | + +--- + +## 7. Workspace Registry + +**Path:** `$PASEO_HOME/projects/workspaces.json` + +Array of workspace records. A workspace is a specific working directory within a project. + +| Field | Type | Description | +|---|---|---| +| `workspaceId` | `string` | Primary key | +| `projectId` | `string` | FK to Project.projectId | +| `cwd` | `string` | Filesystem path | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | +| `displayName` | `string` | | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | + +--- + +## 8. Push Token Store + +**Path:** `$PASEO_HOME/push-tokens.json` + +```json +{ + "tokens": ["ExponentPushToken[...]", ...] +} +``` + +Simple set of Expo push notification tokens. No schema validation — just an array of strings. + +--- + +## Client-side stores (App) + +These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem. + +### Draft Store + +**AsyncStorage key:** `paseo-drafts` (version 2) + +```typescript +{ + drafts: Record, + createModalDraft: DraftRecord | null +} +``` + +### Attachment Store (Web) + +**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments` + +Stores binary attachment blobs keyed by attachment ID. + +### AttachmentMetadata + +| Field | Type | Description | +|---|---|---| +| `id` | `string` | Unique attachment ID | +| `mimeType` | `string` | MIME type | +| `storageType` | `string` | Storage backend identifier | +| `storageKey` | `string` | Key within the storage backend | +| `createdAt` | `number` | Epoch ms | +| `fileName` | `string?` | Original filename | +| `byteSize` | `number?` | Size in bytes | diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 097b59da0..b676a864a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -57,7 +57,7 @@ import { HorizontalScrollProvider, useHorizontalScrollOptional, } from "@/contexts/horizontal-scroll-context"; -import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; +import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { CommandCenter } from "@/components/command-center"; import { ProjectPickerModal } from "@/components/project-picker-modal"; import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog"; @@ -374,7 +374,7 @@ function AppContainer({ const agentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const sidebarWidth = usePanelStore((state) => state.sidebarWidth); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0; useEffect(() => { diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 5fa327b5d..fb9882791 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -3,7 +3,8 @@ import type { ReactNode } from "react"; import { createPortal } from "react-dom"; import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native"; import type { TextInputProps } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; import { BottomSheetModal, @@ -136,7 +137,7 @@ export function AdaptiveModalSheet({ onFilesDropped, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const sheetRef = useRef(null); const dismissingForVisibilityRef = useRef(false); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); @@ -285,7 +286,7 @@ export function AdaptiveModalSheet({ */ export const AdaptiveTextInput = forwardRef( function AdaptiveTextInput(props, ref) { - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); if (isMobile) { return ; diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index bd272fec2..7e3bd9d29 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -1,6 +1,7 @@ import { useCallback, useRef, useState } from "react"; import { Alert, Text, TextInput, View } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Link2 } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; @@ -151,7 +152,7 @@ export function AddHostModal({ const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertDirectConnection } = useHostMutations(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const hostInputRef = useRef(null); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 84cc3ca33..9245ae1f6 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -10,7 +10,8 @@ import { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCallback, useMemo, useState, type ReactElement } from "react"; import { router } from "expo-router"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; @@ -221,7 +222,7 @@ export function AgentList({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const actionClient = useSessionStore((state) => actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 5954ad2af..cc2f22917 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -962,13 +962,14 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat return; } void updatePreferences( - mergeProviderPreferences({ - preferences, - provider: agent.provider, - updates: { - model: modelId, - }, - }), + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + model: modelId, + }, + }), ).catch((error) => { console.warn("[AgentStatusBar] persist model preference failed", error); }); @@ -978,7 +979,7 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat }} favoriteKeys={favoriteKeys} onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[AgentStatusBar] toggle favorite model failed", error); }); }} @@ -991,16 +992,17 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat const activeModelId = modelSelection.activeModelId; if (activeModelId) { void updatePreferences( - mergeProviderPreferences({ - preferences, - provider: agent.provider, - updates: { - model: activeModelId, - thinkingByModel: { - [activeModelId]: thinkingOptionId, + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + model: activeModelId, + thinkingByModel: { + [activeModelId]: thinkingOptionId, + }, }, - }, - }), + }), ).catch((error) => { console.warn("[AgentStatusBar] persist thinking preference failed", error); }); @@ -1014,6 +1016,20 @@ export function AgentStatusBar({ agentId, serverId, onDropdownClose }: AgentStat if (!client) { return; } + void updatePreferences( + (current) => + mergeProviderPreferences({ + preferences: current, + provider: agent.provider, + updates: { + featureValues: { + [featureId]: value, + }, + }, + }), + ).catch((error) => { + console.warn("[AgentStatusBar] persist feature preference failed", error); + }); void client.setAgentFeature(agentId, featureId, value).catch((error) => { console.warn("[AgentStatusBar] setAgentFeature failed", error); }); @@ -1083,7 +1099,7 @@ export function DraftAgentStatusBar({ onSelect={onSelectProviderAndModel} favoriteKeys={favoriteKeys} onToggleFavorite={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); }); }} @@ -1129,7 +1145,7 @@ export function DraftAgentStatusBar({ isModelLoading={isAllModelsLoading} favoriteKeys={favoriteKeys} onToggleFavoriteModel={(provider, modelId) => { - void updatePreferences(toggleFavoriteModel({ preferences, provider, modelId })).catch((error) => { + void updatePreferences((current) => toggleFavoriteModel({ preferences: current, provider, modelId })).catch((error) => { console.warn("[DraftAgentStatusBar] toggle favorite model failed", error); }); }} diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 4ba4c99f3..870fd63a1 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -9,7 +9,8 @@ import { useState, } from "react"; import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useMutation } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import Animated, { @@ -110,7 +111,7 @@ const AgentStreamViewComponent = forwardRef(null); const { theme } = useUnistyles(); const router = useRouter(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const streamRenderStrategy = useMemo( () => resolveStreamRenderStrategy({ @@ -713,7 +714,7 @@ function PermissionRequestCard({ client: DaemonClient | null; }) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const { request } = permission; const isPlanRequest = request.kind === "plan"; diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 5ae484fcb..bac0ac232 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -9,7 +9,8 @@ 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 { useIsCompactFormFactor } from "@/constants/layout"; import { ArrowLeft, ChevronDown, @@ -348,8 +349,7 @@ function ProviderSearchInput({ }) { const { theme } = useUnistyles(); const inputRef = useRef(null); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const InputComponent = isMobile ? BottomSheetTextInput : TextInput; useEffect(() => { diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index cd1cde600..cd517fe06 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -1,6 +1,7 @@ import { View, Pressable, Text, ActivityIndicator, Platform } from "react-native"; import { useState, useEffect, useRef, useCallback } from "react"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useShallow } from "zustand/shallow"; import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; import Animated from "react-native-reanimated"; @@ -162,10 +163,8 @@ export function Composer({ const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const isDesktopWebBreakpoint = - Platform.OS === "web" && - UnistylesRuntime.breakpoint !== "xs" && - UnistylesRuntime.breakpoint !== "sm"; + const isMobile = useIsCompactFormFactor(); + const isDesktopWebBreakpoint = Platform.OS === "web" && !isMobile; const messagePlaceholder = isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 1ab3eb94c..fcfe9a140 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -20,7 +20,7 @@ import { type ExplorerTab, } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; -import { HEADER_INNER_HEIGHT, isCompactFormFactor } from "@/constants/layout"; +import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { GitDiffPane } from "./git-diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -48,7 +48,7 @@ export function ExplorerSidebar({ const { theme } = useUnistyles(); const isScreenFocused = useIsFocused(); const insets = useSafeAreaInsets(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 44761930d..983fd1147 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -10,7 +10,8 @@ import { Platform, } from "react-native"; import { Gesture } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import Animated, { cancelAnimation, Easing, @@ -89,7 +90,7 @@ export function FileExplorerPane({ onOpenFile, }: FileExplorerPaneProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const daemons = useHosts(); diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index b92e204e3..1b6e429b6 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -8,7 +8,8 @@ import { View, Platform, } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; @@ -241,7 +242,7 @@ export function FilePane({ workspaceRoot: string; filePath: string; }) { - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 19f504106..b513c1c3b 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -22,7 +22,8 @@ import { TextStyle, } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { AlignJustify, Archive, @@ -607,7 +608,7 @@ type DiffFlatItem = export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const canUseSplitLayout = Platform.OS === "web" && !isMobile; const router = useRouter(); diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index f7e5a8904..09234adc7 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -5,7 +5,7 @@ import { PanelLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { HeaderToggleButton } from "./header-toggle-button"; import { usePanelStore } from "@/stores/panel-store"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { getShortcutOs } from "@/utils/shortcut-platform"; interface MenuHeaderProps { @@ -44,7 +44,7 @@ export function SidebarMenuToggle({ nativeID = "menu-button", }: SidebarMenuToggleProps = {}) { const { theme } = useUnistyles(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); diff --git a/packages/app/src/components/headers/screen-header.tsx b/packages/app/src/components/headers/screen-header.tsx index a544efdab..4a5a28b22 100644 --- a/packages/app/src/components/headers/screen-header.tsx +++ b/packages/app/src/components/headers/screen-header.tsx @@ -6,7 +6,7 @@ import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE, - isCompactFormFactor, + useIsCompactFormFactor, } from "@/constants/layout"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; @@ -26,7 +26,7 @@ interface ScreenHeaderProps { export function ScreenHeader({ left, right, leftStyle, rightStyle, borderless }: ScreenHeaderProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const padding = useWindowControlsPadding("header"); // Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 653c58942..5d9b27e63 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -50,7 +50,7 @@ import { formatConnectionStatus } from "@/utils/daemons"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, - isCompactFormFactor, + useIsCompactFormFactor, } from "@/constants/layout"; import { buildHostSessionsRoute, @@ -118,7 +118,7 @@ export const LeftSidebar = memo(function LeftSidebar({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 978c21745..cd7161ba9 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -41,7 +41,8 @@ import { Scissors, MicVocal, } from "lucide-react-native"; -import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import Animated, { Easing, cancelAnimation, @@ -1822,7 +1823,7 @@ export const ToolCall = memo(function ToolCall({ const [isExpanded, setIsExpanded] = useState(false); // Check if we're on mobile (use bottom sheet) or desktop (inline expand) - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const effectiveDetail = useMemo(() => { if (detail) { diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 5abd5a2ad..178a36b23 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; import { Alert, Text, View } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Link } from "lucide-react-native"; import type { HostProfile } from "@/types/host-connection"; import { useHosts, useHostMutations } from "@/runtime/host-runtime"; @@ -66,7 +67,7 @@ export function PairLinkModal({ const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const [offerUrl, setOfferUrl] = useState(""); const [isSaving, setIsSaving] = useState(false); diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 771b8d4fa..d6fa504a6 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,6 +1,7 @@ import { useState, useCallback } from "react"; import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; -import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; @@ -63,7 +64,7 @@ const IS_WEB = Platform.OS === "web"; export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const questions = parseQuestions(permission.request.input); const [selections, setSelections] = useState>>({}); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index bed7bbed1..eb0474b4e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -47,7 +47,7 @@ import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; -import { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; +import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { buildHostNewWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; @@ -763,7 +763,7 @@ function ProjectHeaderRow({ }: ProjectHeaderRowProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); - const isMobileBreakpoint = isCompactFormFactor(); + const isMobileBreakpoint = useIsCompactFormFactor(); const handleBeginWorkspaceSetup = useCallback(() => { if (!serverId) { return; @@ -771,6 +771,8 @@ function ProjectHeaderRow({ router.navigate(buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any); onWorkspacePress?.(); }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const toast = useToast(); useKeyboardActionHandler({ handlerId: `worktree-new-${project.projectKey}`, @@ -1850,7 +1852,7 @@ export function SidebarWorkspaceList({ listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const isNative = Platform.OS !== "web"; const pathname = usePathname(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index df1f27e70..af7ef4aca 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -21,7 +21,7 @@ import { import { usePanelStore } from "@/stores/panel-store"; import { toXtermTheme } from "@/utils/to-xterm-theme"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; interface TerminalPaneProps { serverId: string; @@ -92,7 +92,7 @@ export function TerminalPane({ const isAppVisible = useAppVisible(); const { theme } = useUnistyles(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const openAgentList = usePanelStore((state) => state.openAgentList); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 2ff21f875..ca1f04575 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import { createPortal } from "react-dom"; import { Animated, Easing, Platform, Text, ToastAndroid, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { AlertTriangle, CheckCircle2 } from "lucide-react-native"; import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root"; import { @@ -181,7 +182,7 @@ export function ToastViewport({ return null; } - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topOffset = diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 395d1806c..7093d3b51 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -11,7 +11,8 @@ import { StatusBar, useWindowDimensions, } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { BottomSheetModal, BottomSheetScrollView, @@ -261,7 +262,7 @@ export function Combobox({ anchorRef, children, }: ComboboxProps): ReactElement { - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const isDesktopAboveSearch = !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index db3196e03..fef7b5435 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -27,7 +27,8 @@ import { type ViewStyle, } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -347,7 +348,7 @@ export function ContextMenuContent({ testID?: string; }>): ReactElement | null { const context = useContextMenuContext("ContextMenuContent"); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; const bottomSheetRef = useRef(null); diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 2a220ffa9..4a02ad47e 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -1,5 +1,5 @@ import { Platform } from "react-native"; -import { UnistylesRuntime } from "react-native-unistyles"; +import { useUnistyles } from "react-native-unistyles"; import { isElectronRuntime, isElectronRuntimeMac } from "@/desktop/host"; export const FOOTER_HEIGHT = 75; @@ -62,16 +62,13 @@ export function getIsElectronRuntime(): boolean { return result; } -export function isCompactFormFactor(): boolean { - return UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; -} - -export function isDesktopFormFactor(): boolean { - return !isCompactFormFactor(); -} - -export function isTouchDesktopFormFactor(): boolean { - return Platform.OS !== "web" && isDesktopFormFactor(); +/** + * Reactive hook — re-renders the component when the breakpoint changes. + * Always use this instead of reading UnistylesRuntime.breakpoint directly. + */ +export function useIsCompactFormFactor(): boolean { + const { rt } = useUnistyles(); + return rt.breakpoint === "xs" || rt.breakpoint === "sm"; } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index 80d5c191d..2d1271d4c 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "re import { useWindowDimensions } from "react-native"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { getRightSidebarAnimationTargets, @@ -29,7 +29,7 @@ const ExplorerSidebarAnimationContext = createContext state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index f315eee6c..cbc0e9f24 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -10,7 +10,7 @@ import { import { useWindowDimensions } from "react-native"; import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { getLeftSidebarAnimationTargets, @@ -36,7 +36,7 @@ const SidebarAnimationContext = createContext state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index 04d429dec..36e97e9a1 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -4,6 +4,7 @@ import { invokeDesktopCommand } from "@/desktop/electron/invoke"; export interface DesktopAppUpdateCheckResult { hasUpdate: boolean; + readyToInstall: boolean; currentVersion: string | null; latestVersion: string | null; body: string | null; @@ -76,6 +77,7 @@ export async function checkDesktopAppUpdate(): Promise { + if (!isDesktopApp || status !== "pending") { + return undefined; + } + + const intervalId = setInterval(() => { + void checkForUpdates({ silent: true }); + }, PENDING_RECHECK_MS); + + return () => { + clearInterval(intervalId); + }; + }, [checkForUpdates, isDesktopApp, status]); + const installUpdate = useCallback(async () => { if (!isDesktopApp) { return null; diff --git a/packages/app/src/hooks/feature-preferences.test.ts b/packages/app/src/hooks/feature-preferences.test.ts new file mode 100644 index 000000000..7cfca13a6 --- /dev/null +++ b/packages/app/src/hooks/feature-preferences.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { resolveFeatureValues } from "./feature-preferences"; + +describe("feature-preferences", () => { + const features = [ + { + type: "toggle" as const, + id: "fast_mode", + label: "Fast", + value: false, + }, + { + type: "toggle" as const, + id: "plan_mode", + label: "Plan", + value: false, + }, + ]; + + it("restores persisted values for available features", () => { + expect( + resolveFeatureValues({ + features, + persistedFeatureValues: { + fast_mode: true, + unknown_feature: true, + }, + localFeatureValues: {}, + }), + ).toEqual({ + fast_mode: true, + }); + }); + + it("prefers local values over persisted values", () => { + expect( + resolveFeatureValues({ + features, + persistedFeatureValues: { + fast_mode: true, + plan_mode: false, + }, + localFeatureValues: { + fast_mode: false, + }, + }), + ).toEqual({ + fast_mode: false, + plan_mode: false, + }); + }); +}); diff --git a/packages/app/src/hooks/feature-preferences.ts b/packages/app/src/hooks/feature-preferences.ts new file mode 100644 index 000000000..8fc3f1aaf --- /dev/null +++ b/packages/app/src/hooks/feature-preferences.ts @@ -0,0 +1,60 @@ +import type { AgentFeature } from "@server/server/agent/agent-sdk-types"; + +export function pruneFeatureValues( + featureValues: Record, + features: AgentFeature[], +): Record { + const allowedFeatureIds = new Set(features.map((feature) => feature.id)); + let changed = false; + const next: Record = {}; + + for (const [featureId, value] of Object.entries(featureValues)) { + if (!allowedFeatureIds.has(featureId)) { + changed = true; + continue; + } + next[featureId] = value; + } + + return changed ? next : featureValues; +} + +export function applyFeatureValues( + features: AgentFeature[], + featureValues: Record, +): AgentFeature[] { + if (Object.keys(featureValues).length === 0) { + return features; + } + + return features.map((feature) => { + if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { + return feature; + } + + return { + ...feature, + value: featureValues[feature.id], + } as AgentFeature; + }); +} + +export function resolveFeatureValues(args: { + features: AgentFeature[]; + persistedFeatureValues: Record; + localFeatureValues: Record; +}): Record { + const next: Record = {}; + + for (const feature of args.features) { + if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) { + next[feature.id] = args.localFeatureValues[feature.id]; + continue; + } + if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) { + next[feature.id] = args.persistedFeatureValues[feature.id]; + } + } + + return next; +} diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 160c1fdc6..4261f60a2 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -649,30 +649,29 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); const modelId = resolvedModel?.id ?? formState.model; - const nextPreferences = mergeProviderPreferences({ - preferences: preferences ?? {}, - provider: formState.provider, - updates: { - model: modelId || undefined, - mode: formState.modeId || undefined, - ...(modelId && formState.thinkingOptionId - ? { - thinkingByModel: { - [modelId]: formState.thinkingOptionId, - }, - } - : {}), - } satisfies Partial, - }); - - await updatePreferences(nextPreferences); + await updatePreferences((current) => + mergeProviderPreferences({ + preferences: current, + provider: formState.provider, + updates: { + model: modelId || undefined, + mode: formState.modeId || undefined, + ...(modelId && formState.thinkingOptionId + ? { + thinkingByModel: { + [modelId]: formState.thinkingOptionId, + }, + } + : {}), + } satisfies Partial, + }), + ); }, [ availableModels, formState.model, formState.modeId, formState.provider, formState.thinkingOptionId, - preferences?.providerPreferences, updatePreferences, ]); diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index e563a8220..3af185c53 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -1,6 +1,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useId, useMemo } from "react"; -import { UnistylesRuntime } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages"; @@ -60,7 +60,7 @@ export function useCheckoutDiffQuery({ const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const explorerTab = usePanelStore((state) => state.explorerTab); diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index 3aed5b223..7ff55e5c5 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef } from "react"; -import { UnistylesRuntime } from "react-native-unistyles"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { usePanelStore } from "@/stores/panel-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import type { CheckoutStatusResponse } from "@server/shared/messages"; @@ -32,7 +32,7 @@ function fetchCheckoutStatus( export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) { const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const explorerTab = usePanelStore((state) => state.explorerTab); diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index eaef93d6f..3f9a4dce4 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -1,50 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { - AgentFeature, AgentProvider, AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; - -function pruneFeatureValues( - featureValues: Record, - features: AgentFeature[], -): Record { - const allowedFeatureIds = new Set(features.map((feature) => feature.id)); - let changed = false; - const next: Record = {}; - - for (const [featureId, value] of Object.entries(featureValues)) { - if (!allowedFeatureIds.has(featureId)) { - changed = true; - continue; - } - next[featureId] = value; - } - - return changed ? next : featureValues; -} - -function applyFeatureValues( - features: AgentFeature[], - featureValues: Record, -): AgentFeature[] { - if (Object.keys(featureValues).length === 0) { - return features; - } - - return features.map((feature) => { - if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { - return feature; - } - - return { - ...feature, - value: featureValues[feature.id], - } as AgentFeature; - }); -} +import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; +import { + applyFeatureValues, + pruneFeatureValues, + resolveFeatureValues, +} from "./feature-preferences"; type DraftFeatureConfig = Pick< AgentSessionConfig, @@ -60,10 +26,15 @@ export function useDraftAgentFeatures(input: { thinkingOptionId: string | null | undefined; }) { const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; - const [featureValues, setFeatureValues] = useState>({}); + const [localFeatureValues, setLocalFeatureValues] = useState>({}); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const { preferences, updatePreferences } = useFormPreferences(); const normalizedCwd = cwd?.trim() || ""; + const persistedFeatureValues = useMemo( + () => preferences.providerPreferences?.[provider]?.featureValues ?? {}, + [preferences.providerPreferences, provider], + ); const draftConfig = useMemo(() => { if (!normalizedCwd) { @@ -102,28 +73,56 @@ export function useDraftAgentFeatures(input: { return payload.features ?? []; }, }); + const availableFeatures = featuresQuery.data ?? []; + const featureValues = useMemo( + () => + resolveFeatureValues({ + features: availableFeatures, + persistedFeatureValues, + localFeatureValues, + }), + [availableFeatures, localFeatureValues, persistedFeatureValues], + ); const features = useMemo(() => { - return applyFeatureValues(featuresQuery.data ?? [], featureValues); - }, [featureValues, featuresQuery.data]); + return applyFeatureValues(availableFeatures, featureValues); + }, [availableFeatures, featureValues]); useEffect(() => { - const next = pruneFeatureValues(featureValues, features); - if (next !== featureValues) { - setFeatureValues(next); + setLocalFeatureValues({}); + }, [provider]); + + useEffect(() => { + const next = pruneFeatureValues(localFeatureValues, availableFeatures); + if (next !== localFeatureValues) { + setLocalFeatureValues(next); } - }, [featureValues, features]); + }, [availableFeatures, localFeatureValues]); const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; const setFeatureValue = useCallback((featureId: string, value: unknown) => { - setFeatureValues((current) => { + setLocalFeatureValues((current) => { if (Object.is(current[featureId], value)) { return current; } return { ...current, [featureId]: value }; }); - }, []); + void updatePreferences( + (current) => + mergeProviderPreferences({ + preferences: current, + provider, + updates: { + featureValues: { + [featureId]: value, + }, + }, + }), + ).catch((error) => { + console.warn("[useDraftAgentFeatures] persist feature preference failed", error); + }); + }, [provider, updatePreferences]); return { features, diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index 55bb8fc56..4dd277420 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -59,6 +59,41 @@ describe("mergeProviderPreferences", () => { }, }); }); + + it("merges feature values without dropping existing entries", () => { + expect( + mergeProviderPreferences({ + preferences: { + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + featureValues: { + fast_mode: true, + }, + }, + }, + }, + provider: "codex", + updates: { + featureValues: { + plan_mode: true, + }, + }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4", + featureValues: { + fast_mode: true, + plan_mode: true, + }, + }, + }, + }); + }); }); describe("favorite model preferences", () => { diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index ff04c8e84..cd14ebf66 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -25,6 +25,7 @@ const providerPreferencesSchema = z.object({ model: z.string().optional(), mode: z.string().optional(), thinkingByModel: z.record(z.string()).optional(), + featureValues: z.record(z.unknown()).optional(), }); const formPreferencesSchema = z.object({ @@ -53,7 +54,9 @@ async function loadFormPreferences(): Promise { export interface UseFormPreferencesReturn { preferences: FormPreferences; isLoading: boolean; - updatePreferences: (updates: Partial) => Promise; + updatePreferences: ( + updates: Partial | ((current: FormPreferences) => FormPreferences), + ) => Promise; } export function mergeProviderPreferences(args: { @@ -71,6 +74,13 @@ export function mergeProviderPreferences(args: { ...existing.thinkingByModel, ...updates.thinkingByModel, }; + const nextFeatureValues = + updates.featureValues === undefined + ? existing.featureValues + : { + ...existing.featureValues, + ...updates.featureValues, + }; return { ...preferences, @@ -81,6 +91,7 @@ export function mergeProviderPreferences(args: { ...existing, ...updates, ...(nextThinkingByModel ? { thinkingByModel: nextThinkingByModel } : {}), + ...(nextFeatureValues ? { featureValues: nextFeatureValues } : {}), }, }, }; @@ -133,11 +144,12 @@ export function useFormPreferences(): UseFormPreferencesReturn { const preferences = data ?? DEFAULT_FORM_PREFERENCES; const updatePreferences = useCallback( - async (updates: Partial) => { + async (updates: Partial | ((current: FormPreferences) => FormPreferences)) => { const prev = queryClient.getQueryData(FORM_PREFERENCES_QUERY_KEY) ?? DEFAULT_FORM_PREFERENCES; - const next = { ...prev, ...updates }; + const next = + typeof updates === "function" ? updates(prev) : { ...prev, ...updates }; queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); }, diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index f82a48ad7..f817838f8 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -40,7 +40,7 @@ import { } from "@/runtime/host-runtime"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; -import { MAX_CONTENT_WIDTH, isCompactFormFactor } from "@/constants/layout"; +import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { WelcomeScreen } from "@/components/welcome-screen"; import type { Agent } from "@/contexts/session-context"; import { encodeImages } from "@/utils/encode-images"; @@ -242,7 +242,7 @@ function DraftAgentScreenContent({ commandDraftConfig, statusControls, } = composerState; - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 65e64902a..0d6859172 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -8,7 +8,7 @@ import { MenuHeader } from "@/components/headers/menu-header"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; -import { isCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import { useIsCompactFormFactor, HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; export function OpenProjectScreen({ serverId }: { serverId: string }) { @@ -17,7 +17,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { const hasHydrated = useSessionStore((s) => s.sessions[serverId]?.hasHydratedWorkspaces ?? false); const hasProjects = useSessionStore((s) => (s.sessions[serverId]?.workspaces?.size ?? 0) > 0); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); useEffect(() => { if (!isCompactLayout) { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 29fdb72f7..b7c190b10 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -63,7 +63,7 @@ import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pc import { useVoiceAudioEngineOptional } from "@/contexts/voice-context"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; -import { isCompactFormFactor } from "@/constants/layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; import { getProviderIcon } from "@/components/provider-icons"; import { ProviderDiagnosticSheet } from "@/components/provider-diagnostic-sheet"; @@ -806,7 +806,7 @@ function DesktopAppUpdateRow() { {statusText} {availableUpdate?.latestVersion ? ( - New version available: {formatVersionWithPrefix(availableUpdate.latestVersion)} + Ready to install: {formatVersionWithPrefix(availableUpdate.latestVersion)} ) : null} {errorMessage ? {errorMessage} : null} @@ -1013,7 +1013,7 @@ export default function SettingsScreen() { } }, [isPlaybackTestRunning, voiceAudioEngine]); - const isCompactLayout = isCompactFormFactor(); + const isCompactLayout = useIsCompactFormFactor(); const sections = getSettingsSections({ isDesktopApp }); const hostsProps: HostsSectionProps = { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d5c84e4eb..a2990e549 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -121,7 +121,7 @@ import { closeBulkWorkspaceTabs, } from "@/screens/workspace/workspace-bulk-close"; import { findAdjacentPane } from "@/utils/split-navigation"; -import { isCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; +import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; const TERMINALS_QUERY_STALE_TIME = 5_000; const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000; @@ -599,7 +599,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps) const insets = useSafeAreaInsets(); const mainBackgroundColor = theme.colors.surfaceWorkspace; const toast = useToast(); - const isMobile = isCompactFormFactor(); + const isMobile = useIsCompactFormFactor(); const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled); const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? ""; diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 20caca0ea..0704b3e01 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -7,7 +7,7 @@ "scripts": { "build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", - "dev": "npm run build:main && wait-on tcp:8081 && electron .", + "dev": "./scripts/dev.sh", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, diff --git a/packages/desktop/scripts/dev.sh b/packages/desktop/scripts/dev.sh new file mode 100755 index 000000000..a4756af9a --- /dev/null +++ b/packages/desktop/scripts/dev.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_DIR="$(cd "$DESKTOP_DIR/../app" && pwd)" +ROOT_DIR="$(cd "$DESKTOP_DIR/../.." && pwd)" + +# Build the Electron main process +npm run build:main + +# Get a random available port for Metro +EXPO_PORT=$("$ROOT_DIR/node_modules/.bin/get-port") +export EXPO_PORT + +echo "══════════════════════════════════════════════════════" +echo " Paseo Desktop Dev" +echo "══════════════════════════════════════════════════════" +echo " Metro: http://localhost:${EXPO_PORT}" +echo "══════════════════════════════════════════════════════" + +# Launch Metro + Electron together, kill both on exit +"$ROOT_DIR/node_modules/.bin/concurrently" \ + --kill-others \ + --names "metro,electron" \ + --prefix-colors "magenta,cyan" \ + "cd '$APP_DIR' && npx expo start --port $EXPO_PORT" \ + "$ROOT_DIR/node_modules/.bin/wait-on tcp:$EXPO_PORT && EXPO_DEV_URL=http://localhost:$EXPO_PORT electron '$DESKTOP_DIR'" diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index d1c1dca8f..61ecee1d9 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -7,6 +7,7 @@ import { autoUpdater, type UpdateInfo } from "electron-updater"; export type AppUpdateCheckResult = { hasUpdate: boolean; + readyToInstall: boolean; currentVersion: string; latestVersion: string; body: string | null; @@ -24,19 +25,84 @@ export type AppUpdateInstallResult = { // --------------------------------------------------------------------------- let cachedUpdateInfo: UpdateInfo | null = null; +let downloadedUpdateVersion: string | null = null; let downloading = false; +let autoUpdaterConfigured = false; // --------------------------------------------------------------------------- // Configuration // --------------------------------------------------------------------------- function configureAutoUpdater(): void { - // Don't auto-download — the user triggers install explicitly. - autoUpdater.autoDownload = false; + // Download updates in the background and only prompt once they are ready to install. + autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; // Suppress built-in dialogs; the renderer handles UI. autoUpdater.autoRunAppAfterInstall = true; + + if (autoUpdaterConfigured) { + return; + } + + autoUpdaterConfigured = true; + + autoUpdater.on("update-available", (info) => { + cachedUpdateInfo = info; + downloadedUpdateVersion = null; + downloading = true; + }); + + autoUpdater.on("update-downloaded", (info) => { + cachedUpdateInfo = info; + downloadedUpdateVersion = info.version; + downloading = false; + }); + + autoUpdater.on("update-not-available", () => { + cachedUpdateInfo = null; + downloadedUpdateVersion = null; + downloading = false; + }); + + autoUpdater.on("error", (error) => { + downloading = false; + console.error("[auto-updater] Updater event failed:", error); + }); +} + +function isReadyToInstallVersion(version: string): boolean { + return downloadedUpdateVersion === version; +} + +function buildCheckResult(input: { + currentVersion: string; + hasUpdate: boolean; + readyToInstall: boolean; + info?: UpdateInfo | null; +}): AppUpdateCheckResult { + const { currentVersion, hasUpdate, readyToInstall, info } = input; + + return { + hasUpdate, + readyToInstall, + currentVersion, + latestVersion: info?.version ?? currentVersion, + body: typeof info?.releaseNotes === "string" ? info.releaseNotes : null, + date: typeof info?.releaseDate === "string" ? info.releaseDate : null, + }; +} + +function scheduleQuitAndInstall(onBeforeQuit?: () => Promise): void { + // Use a short delay to allow the renderer to receive the response. + setTimeout(async () => { + try { + if (onBeforeQuit) await onBeforeQuit(); + autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); + } catch (error) { + console.error("[auto-updater] quitAndInstall failed:", error); + } + }, 1500); } // --------------------------------------------------------------------------- @@ -45,28 +111,34 @@ function configureAutoUpdater(): void { export async function checkForAppUpdate(currentVersion: string): Promise { if (!app.isPackaged) { - return { - hasUpdate: false, + return buildCheckResult({ currentVersion, - latestVersion: currentVersion, - body: null, - date: null, - }; + hasUpdate: false, + readyToInstall: false, + }); } configureAutoUpdater(); + const cachedVersion = cachedUpdateInfo?.version ?? null; + if (cachedVersion && cachedVersion !== currentVersion) { + return buildCheckResult({ + currentVersion, + hasUpdate: true, + readyToInstall: isReadyToInstallVersion(cachedVersion), + info: cachedUpdateInfo, + }); + } + try { const result = await autoUpdater.checkForUpdates(); if (!result || !result.updateInfo) { - return { - hasUpdate: false, + return buildCheckResult({ currentVersion, - latestVersion: currentVersion, - body: null, - date: null, - }; + hasUpdate: false, + readyToInstall: false, + }); } const info = result.updateInfo; @@ -75,24 +147,31 @@ export async function checkForAppUpdate(currentVersion: string): Promise { - try { - if (onBeforeQuit) await onBeforeQuit(); - autoUpdater.quitAndInstall(/* isSilent */ false, /* isForceRunAfter */ true); - } catch (error) { - console.error("[auto-updater] quitAndInstall failed:", error); - } - }, 1500); + downloadedUpdateVersion = readyVersion; + downloading = false; + scheduleQuitAndInstall(onBeforeQuit); return { installed: true, - version: cachedUpdateInfo.version, + version: readyVersion, message: "Update downloaded. The app will restart shortly.", }; } catch (error) { diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index e581a3600..c2b460cc9 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -8,6 +8,7 @@ inheritLoginShellEnv(); import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; +import { execFileSync } from "node:child_process"; import { app, BrowserWindow, ipcMain, nativeImage, net, protocol } from "electron"; import { registerDaemonManager } from "./daemon/daemon-manager.js"; import { @@ -38,6 +39,38 @@ const APP_SCHEME = "paseo"; const OPEN_PROJECT_EVENT = "paseo:event:open-project"; app.setName("Paseo"); +// In dev mode, detect git worktrees and isolate each instance so multiple +// Electron windows can run side-by-side (separate userData = separate lock). +let devWorktreeName: string | null = null; +if (!app.isPackaged) { + try { + const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf-8", + timeout: 3000, + }).trim(); + devWorktreeName = path.basename(topLevel); + // Main checkout (e.g. "paseo") gets default userData — only worktrees diverge. + const commonDir = path.resolve( + topLevel, + execFileSync("git", ["rev-parse", "--git-common-dir"], { + cwd: topLevel, + encoding: "utf-8", + timeout: 3000, + }).trim(), + ); + const isWorktree = path.resolve(topLevel, ".git") !== commonDir; + if (isWorktree) { + app.setPath("userData", path.join(app.getPath("appData"), `Paseo-${devWorktreeName}`)); + log.info("[worktree] isolated userData for worktree:", devWorktreeName); + } else { + devWorktreeName = null; + } + } catch { + devWorktreeName = null; + } +} + + // Allow users to pass Chromium flags via PASEO_ELECTRON_FLAGS for debugging // rendering issues (e.g. "--disable-gpu --ozone-platform=x11"). // Must run before app.whenReady(). @@ -127,7 +160,9 @@ async function createMainWindow(): Promise { const iconPath = getWindowIconPath(); const systemTheme = resolveSystemWindowTheme(); + const title = devWorktreeName ? `Paseo (${devWorktreeName})` : "Paseo"; const mainWindow = new BrowserWindow({ + title, width: 1200, height: 800, show: false, @@ -144,6 +179,10 @@ async function createMainWindow(): Promise { }, }); + if (devWorktreeName) { + app.dock?.setBadge(devWorktreeName); + } + setupWindowResizeEvents(mainWindow); setupDefaultContextMenu(mainWindow); setupDragDropPrevention(mainWindow); diff --git a/packages/server/.env.example b/packages/server/.env.example index f9771f268..960466c82 100644 --- a/packages/server/.env.example +++ b/packages/server/.env.example @@ -18,3 +18,6 @@ PASEO_LISTEN=127.0.0.1:6767 # Debug recordings (dictation + STT input + TTS output) # When enabled, recordings are saved under `${cwd}/.debug/recordings/` PASEO_DICTATION_DEBUG=1 + +# Enable verbose Claude SDK stream logging (trace-level per-token logs) +PASEO_CLAUDE_DEBUG=1 diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 5b77f335b..7cf590d96 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1047,6 +1047,8 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { }; } +const claudeDebug = process.env.PASEO_CLAUDE_DEBUG === "1"; + export class ClaudeAgentClient implements AgentClient { readonly provider: "claude" = "claude"; readonly capabilities = CLAUDE_CAPABILITIES; @@ -2453,15 +2455,17 @@ class ClaudeAgentSession implements AgentSession { while (!this.closed && this.query === activeQuery) { try { for await (const message of activeQuery) { - this.logger.trace( - { - claudeSessionId: this.claudeSessionId, - messageType: message.type, - messageSubtype: "subtype" in message ? message.subtype : undefined, - messageUuid: "uuid" in message ? message.uuid : undefined, - }, - "Claude query pump: raw SDK message", - ); + if (claudeDebug) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + messageType: message.type, + messageSubtype: "subtype" in message ? message.subtype : undefined, + messageUuid: "uuid" in message ? message.uuid : undefined, + }, + "Claude query pump: raw SDK message", + ); + } consecutiveInterruptAbortRecoveries = 0; if (await this.handleMissingResumedConversation(message, activeQuery)) { return; @@ -2537,14 +2541,16 @@ class ClaudeAgentSession implements AgentSession { const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null; const identifiers = readEventIdentifiers(message); - this.logger.trace( - { - claudeSessionId: this.claudeSessionId, - messageType: message.type, - turnId, - }, - "Claude query pump: SDK message", - ); + if (claudeDebug) { + this.logger.trace( + { + claudeSessionId: this.claudeSessionId, + messageType: message.type, + turnId, + }, + "Claude query pump: SDK message", + ); + } const messageEvents = this.translateMessageToEvents(message, { suppressAssistantText: true, diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index ec5500091..ece613697 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -14,6 +14,14 @@ import { } from "./test-utils/index.js"; import { getFullAccessConfig, getAskModeConfig } from "./daemon-e2e/agent-configs.js"; import { chunkPcm16, parsePcm16MonoWav, wordSimilarity } from "./test-utils/dictation-e2e.js"; +import type { + AgentClient, + AgentPersistenceHandle, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, +} from "./agent/agent-sdk-types.js"; const openaiApiKey = process.env.OPENAI_API_KEY ?? null; @@ -94,6 +102,116 @@ function waitForSignal( }); } +class NonPersistentReloadSession implements AgentSession { + readonly provider = "claude" as const; + readonly id = null; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + } as const; + + constructor(private readonly onClose: () => void) {} + + async run(): Promise { + return { + sessionId: "non-persistent", + finalText: "", + timeline: [], + }; + } + + async startTurn(): Promise<{ turnId: string }> { + return { turnId: "non-persistent-turn" }; + } + + subscribe(_callback: (event: AgentStreamEvent) => void): () => void { + return () => undefined; + } + + async *streamHistory(): AsyncGenerator { + return; + } + + async getRuntimeInfo() { + return { + provider: "claude" as const, + sessionId: null, + model: null, + modeId: null, + }; + } + + async getAvailableModes(): Promise<[]> { + return []; + } + + async getCurrentMode(): Promise { + return null; + } + + async setMode(_modeId: string): Promise {} + + getPendingPermissions() { + return []; + } + + async respondToPermission(): Promise {} + + describePersistence(): AgentPersistenceHandle | null { + return null; + } + + async interrupt(): Promise {} + + async close(): Promise { + this.onClose(); + } +} + +class NonPersistentReloadClient implements AgentClient { + readonly provider = "claude" as const; + readonly capabilities = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + } as const; + createSessionCalls = 0; + resumeSessionCalls = 0; + closeCalls = 0; + + async isAvailable(): Promise { + return true; + } + + async createSession(_config: AgentSessionConfig): Promise { + this.createSessionCalls += 1; + return new NonPersistentReloadSession(() => { + this.closeCalls += 1; + }); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + _overrides?: Partial, + ): Promise { + this.resumeSessionCalls += 1; + return new NonPersistentReloadSession(() => { + this.closeCalls += 1; + }); + } + + async listModels() { + return []; + } +} + describe("daemon client E2E", () => { let ctx: DaemonTestContext; @@ -281,6 +399,38 @@ describe("daemon client E2E", () => { } }, 120000); + test("refresh_agent rebuilds a live agent even when it has no persistence handle", async () => { + const cwd = tmpCwd(); + const client = new NonPersistentReloadClient(); + const localCtx = await createDaemonTestContext({ + agentClients: { + claude: client, + }, + }); + + try { + const created = await localCtx.client.createAgent({ + config: { + provider: "claude", + cwd, + }, + }); + + expect(client.createSessionCalls).toBe(1); + expect(client.resumeSessionCalls).toBe(0); + expect(client.closeCalls).toBe(0); + + await localCtx.client.refreshAgent(created.id); + + expect(client.createSessionCalls).toBe(2); + expect(client.resumeSessionCalls).toBe(0); + expect(client.closeCalls).toBe(1); + } finally { + await localCtx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }); + test("resume_agent auto-unarchives archived agents", async () => { const cwd = tmpCwd(); try { diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index 4b7afd5b2..981ceb798 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -50,7 +50,7 @@ const LOG_LEVEL_PRIORITIES: Record = { const DEFAULT_CONSOLE_LEVEL: LogLevel = "info"; const DEFAULT_CONSOLE_FORMAT: LogFormat = "pretty"; -const DEFAULT_FILE_LEVEL: LogLevel = "trace"; +const DEFAULT_FILE_LEVEL: LogLevel = "debug"; const DEFAULT_FILE_ROTATE_SIZE = "10m"; const DEFAULT_FILE_ROTATE_MAX_FILES = 2; const DEFAULT_DAEMON_LOG_FILENAME = "daemon.log"; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index fb62fcdc5..3d8e8b8fa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -217,6 +217,7 @@ function clientSupportsAllProviders(appVersion: string | null): boolean { function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { return isAppVersionAtLeast(appVersion, MIN_VERSION_FLEXIBLE_EDITOR_IDS); } + const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; const WORKSPACE_GIT_WATCH_REMOVED_FINGERPRINT = "__removed__"; const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 1f6e51915..1730ba4e3 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1130,6 +1130,7 @@ export const WorkspaceSetupStatusRequestSchema = z.object({ requestId: z.string(), }); + // TODO(2026-07): Remove once most clients are on >=0.1.50 and support arbitrary editor ids. export const LEGACY_EDITOR_TARGET_IDS = [ "cursor", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 16b1b27cb..9ef16a277 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1946,6 +1946,7 @@ const ReviewDecisionSchema = z type CheckRunNode = z.infer; type StatusContextNode = z.infer; + async function resolveGhPath(): Promise { if (cachedGhPath === undefined) { cachedGhPath = await findExecutable("gh");