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 75de47889..03483af99 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"; @@ -372,7 +372,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 a3ccd5f28..c5f3edbff 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, @@ -119,7 +120,7 @@ export function AdaptiveModalSheet({ testID, }: 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]); @@ -239,7 +240,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-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 8f549b05c..69d9d7e4d 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.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"; @@ -154,10 +155,8 @@ export function AgentInputArea({ 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/agent-list.tsx b/packages/app/src/components/agent-list.tsx index dd2b1af18..63384bb64 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"; @@ -214,7 +215,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-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 2fc15dba1..d96d69a9c 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, { @@ -109,7 +110,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({ @@ -707,7 +708,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 2c90b8425..745e0369a 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/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 5ffe4f365..41e3dcf23 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -21,7 +21,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 bbae09de9..5ae598445 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -44,7 +44,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 { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; @@ -713,7 +713,7 @@ function ProjectHeaderRow({ }: ProjectHeaderRowProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); - const isMobileBreakpoint = isCompactFormFactor(); + const isMobileBreakpoint = useIsCompactFormFactor(); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const toast = useToast(); @@ -1606,7 +1606,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 9d709cbd7..012d181a7 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/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/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 69f7c0c05..534bf759b 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"; @@ -235,7 +235,7 @@ function DraftAgentScreenContent({ isCreateFlow: true, onlineServerIds, }); - 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 f87abed98..507a5a220 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"; @@ -1014,7 +1014,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 186d81283..ab53f23ac 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -116,7 +116,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 NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; @@ -591,7 +591,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)) ?? "";