From f6579dbeb461e7780e89f0b25ecfafe03efbe4f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 21 Mar 2026 20:22:15 +0700 Subject: [PATCH] Switch to double quotes and reformat codebase with Biome --- .prettierignore | 21 - .prettierrc | 7 - biome.json | 32 + package-lock.json | 237 +- package.json | 6 +- packages/app/app.config.js | 7 +- packages/app/e2e/fixtures.ts | 49 +- packages/app/e2e/global-setup.ts | 233 +- .../app/e2e/helpers/agent-bottom-anchor.ts | 44 +- packages/app/e2e/helpers/app.ts | 333 +- packages/app/e2e/helpers/workspace-tabs.ts | 2 +- packages/app/e2e/helpers/workspace-ui.ts | 34 +- packages/app/e2e/helpers/workspace.ts | 36 +- packages/app/eslint.config.js | 6 +- packages/app/playwright.config.ts | 21 +- packages/app/playwright.firefox.config.ts | 3 +- packages/app/playwright.webkit.tmp.config.ts | 22 +- packages/app/scripts/reset-project.js | 4 +- packages/app/src/app-support/index-startup.ts | 38 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 8 +- packages/app/src/app/h/[serverId]/index.tsx | 25 +- .../workspace/[workspaceId]/_layout.tsx | 82 +- packages/app/src/app/index.tsx | 102 +- packages/app/src/app/pair-scan.tsx | 41 +- packages/app/src/app/settings.tsx | 4 +- packages/app/src/app/welcome.tsx | 4 +- .../local-file-attachment-store.ts | 7 +- packages/app/src/attachments/service.ts | 14 +- packages/app/src/attachments/store.ts | 8 +- .../attachments/use-attachment-preview-url.ts | 14 +- .../web/indexeddb-attachment-store.ts | 15 +- .../app/src/components/active-processes.tsx | 41 +- .../src/components/adaptive-modal-sheet.tsx | 41 +- .../src/components/add-host-method-modal.tsx | 19 +- .../app/src/components/add-host-modal.tsx | 54 +- .../app/src/components/agent-activity.tsx | 192 +- .../agent-form/agent-form-dropdowns.tsx | 136 +- .../agent-input-area.status-controls.test.ts | 30 +- .../agent-input-area.status-controls.ts | 4 +- .../app/src/components/agent-input-area.tsx | 685 +- packages/app/src/components/agent-list.tsx | 285 +- .../src/components/agent-status-bar.test.ts | 94 +- .../app/src/components/agent-status-bar.tsx | 472 +- .../src/components/agent-status-bar.utils.ts | 61 +- .../components/agent-stream-render-model.ts | 2 +- .../agent-stream-render-strategy.test.ts | 24 +- .../app/src/components/agent-stream-view.tsx | 1221 ++-- .../agent-stream-web-virtualization.test.ts | 22 +- .../agent-stream-web-virtualization.ts | 6 +- .../src/components/archived-agent-callout.tsx | 72 +- .../app/src/components/artifact-drawer.tsx | 31 +- .../app/src/components/audio-debug-notice.tsx | 21 +- .../components/combined-model-selector.tsx | 195 +- .../app/src/components/command-center.tsx | 57 +- .../app/src/components/connection-status.tsx | 14 +- .../app/src/components/dictation-controls.tsx | 57 +- .../components/dictation-status-notice.tsx | 19 +- packages/app/src/components/diff-viewer.tsx | 3 +- .../app/src/components/download-toast.tsx | 7 +- .../src/components/draggable-list.native.tsx | 18 +- .../app/src/components/draggable-list.web.tsx | 34 +- packages/app/src/components/empty-state.tsx | 5 +- .../app/src/components/explorer-sidebar.tsx | 58 +- .../app/src/components/file-drop-zone.tsx | 12 +- .../app/src/components/file-explorer-pane.tsx | 84 +- packages/app/src/components/file-pane.tsx | 174 +- .../src/components/git-actions-policy.test.ts | 14 +- .../app/src/components/git-actions-policy.ts | 14 +- .../components/git-actions-split-button.tsx | 4 +- packages/app/src/components/git-diff-pane.tsx | 849 +-- .../src/components/headers/back-header.tsx | 11 +- .../src/components/headers/menu-header.tsx | 19 +- .../src/components/headers/screen-header.tsx | 11 +- .../icons/source-control-panel-icon.tsx | 30 +- .../components/keyboard-shortcuts-dialog.tsx | 2 +- packages/app/src/components/left-sidebar.tsx | 454 +- packages/app/src/components/message-input.tsx | 963 +-- packages/app/src/components/message.tsx | 491 +- .../src/components/mode-selector-modal.tsx | 26 +- .../app/src/components/name-host-modal.tsx | 11 +- .../app/src/components/pair-link-modal.tsx | 36 +- .../src/components/project-picker-modal.tsx | 60 +- packages/app/src/components/provider-icons.ts | 10 +- .../app/src/components/question-form-card.tsx | 146 +- packages/app/src/components/resize-handle.tsx | 9 +- .../sidebar-agent-list-skeleton.tsx | 15 +- .../src/components/sidebar-workspace-list.tsx | 1315 ++-- .../src/components/sortable-inline-list.tsx | 1 - .../components/sortable-inline-list.web.tsx | 23 +- .../split-container-pane-focus.test.ts | 10 +- .../split-container-tab-drop-preview.test.ts | 6 +- .../split-container-tab-drop-preview.ts | 4 +- .../app/src/components/split-container.tsx | 114 +- .../app/src/components/split-drop-zone.tsx | 18 +- .../src/components/stream-strategy-native.tsx | 27 +- .../src/components/stream-strategy-web.tsx | 693 +- .../app/src/components/stream-strategy.ts | 60 +- packages/app/src/components/synced-loader.tsx | 19 +- .../app/src/components/terminal-emulator.tsx | 38 +- packages/app/src/components/terminal-pane.tsx | 206 +- packages/app/src/components/toast-host.tsx | 111 +- .../app/src/components/tool-call-details.tsx | 343 +- .../app/src/components/tool-call-sheet.tsx | 27 +- .../components/ui/autocomplete-utils.test.ts | 16 +- .../src/components/ui/autocomplete-utils.ts | 4 +- .../app/src/components/ui/autocomplete.tsx | 160 +- .../components/ui/combobox-keyboard.test.ts | 21 +- .../src/components/ui/combobox-keyboard.ts | 1 - .../app/src/components/ui/combobox-options.ts | 80 +- packages/app/src/components/ui/combobox.tsx | 546 +- .../app/src/components/ui/context-menu.tsx | 68 +- .../components/ui/dropdown-menu-floating.ts | 2 - .../app/src/components/ui/dropdown-menu.tsx | 53 +- .../src/components/ui/segmented-control.tsx | 5 +- packages/app/src/components/ui/tooltip.tsx | 44 +- .../use-bottom-anchor-controller.test.ts | 39 +- .../use-bottom-anchor-controller.ts | 77 +- packages/app/src/components/voice-button.tsx | 62 +- .../components/voice-compact-indicator.tsx | 18 +- packages/app/src/components/voice-panel.tsx | 14 +- packages/app/src/components/volume-meter.tsx | 53 +- .../components/web-desktop-scrollbar.math.ts | 18 +- .../src/components/web-desktop-scrollbar.tsx | 41 +- .../app/src/components/welcome-screen.tsx | 136 +- packages/app/src/constants/theme.ts | 38 +- .../explorer-sidebar-animation-context.tsx | 18 +- .../contexts/horizontal-scroll-context.tsx | 4 +- packages/app/src/contexts/session-context.tsx | 363 +- .../contexts/session-stream-lifecycle.test.ts | 12 +- .../src/contexts/session-stream-lifecycle.ts | 2 +- .../contexts/session-stream-reducers.test.ts | 44 +- .../src/contexts/session-stream-reducers.ts | 63 +- .../session-timeline-bootstrap-policy.test.ts | 6 +- .../session-timeline-bootstrap-policy.ts | 3 +- .../session-timeline-seq-gate.test.ts | 12 +- .../src/contexts/session-timeline-seq-gate.ts | 7 +- .../contexts/sidebar-animation-context.tsx | 34 +- packages/app/src/contexts/toast-context.tsx | 6 +- packages/app/src/contexts/voice-context.tsx | 8 +- .../attachments/desktop-attachment-store.ts | 4 +- .../attachments/desktop-file-commands.ts | 17 +- .../attachments/desktop-preview-url.ts | 13 +- .../components/desktop-permission-row.tsx | 4 +- .../components/desktop-updates-section.tsx | 450 +- .../daemon/desktop-daemon-transport.test.ts | 44 +- .../daemon/desktop-daemon-transport.ts | 6 +- .../app/src/desktop/daemon/desktop-daemon.ts | 196 +- packages/app/src/desktop/electron/events.ts | 2 +- packages/app/src/desktop/electron/invoke.ts | 2 +- packages/app/src/desktop/host.ts | 15 +- .../permissions/desktop-permissions.test.ts | 198 +- .../permissions/desktop-permissions.ts | 330 +- .../permissions/use-desktop-permissions.ts | 15 +- packages/app/src/desktop/pick-directory.ts | 18 +- .../desktop/updates/desktop-updates.test.ts | 128 +- .../src/desktop/updates/desktop-updates.ts | 112 +- .../app/src/desktop/updates/update-banner.tsx | 25 +- .../updates/use-desktop-app-updater.ts | 178 +- .../dictation/dictation-stream-sender.test.ts | 6 +- .../src/dictation/dictation-stream-sender.ts | 26 +- .../app/src/hooks/checkout-diff-order.test.ts | 11 +- packages/app/src/hooks/checkout-diff-order.ts | 4 +- .../src/hooks/checkout-status-revalidation.ts | 7 +- .../src/hooks/image-attachment-picker.test.ts | 4 +- .../app/src/hooks/image-attachment-picker.ts | 8 +- .../src/hooks/use-agent-attention-clear.ts | 17 +- .../app/src/hooks/use-agent-autocomplete.ts | 242 +- .../app/src/hooks/use-agent-commands-query.ts | 6 +- .../src/hooks/use-agent-form-state.test.ts | 25 +- .../app/src/hooks/use-agent-form-state.ts | 88 +- .../hooks/use-agent-initialization.test.ts | 12 +- .../app/src/hooks/use-agent-initialization.ts | 38 +- .../app/src/hooks/use-agent-input-draft.ts | 144 +- .../use-agent-screen-state-machine.test.ts | 10 +- .../hooks/use-agent-screen-state-machine.ts | 18 +- .../app/src/hooks/use-aggregated-agents.ts | 9 +- packages/app/src/hooks/use-all-agents-list.ts | 14 +- .../app/src/hooks/use-archive-agent.test.ts | 13 +- packages/app/src/hooks/use-archive-agent.ts | 23 +- .../src/hooks/use-audio-recorder.native.ts | 123 +- .../app/src/hooks/use-audio-recorder.web.ts | 13 +- packages/app/src/hooks/use-autocomplete.ts | 12 +- .../app/src/hooks/use-checkout-diff-query.ts | 22 +- .../hooks/use-checkout-status-query.test.ts | 15 +- .../src/hooks/use-checkout-status-query.ts | 12 +- packages/app/src/hooks/use-client-activity.ts | 4 +- packages/app/src/hooks/use-color-scheme.ts | 2 +- .../app/src/hooks/use-color-scheme.web.ts | 6 +- packages/app/src/hooks/use-command-center.ts | 61 +- .../use-delayed-history-refresh-toast.tsx | 7 +- .../src/hooks/use-dictation-audio-source.d.ts | 1 - .../use-dictation-audio-source.native.ts | 5 +- .../hooks/use-dictation-audio-source.types.ts | 1 - .../hooks/use-dictation-audio-source.web.ts | 54 +- packages/app/src/hooks/use-dictation.ts | 42 +- .../src/hooks/use-draft-agent-create-flow.ts | 199 +- .../src/hooks/use-explorer-open-gesture.ts | 28 +- packages/app/src/hooks/use-favicon-status.ts | 12 +- packages/app/src/hooks/use-file-drop-zone.ts | 77 +- .../src/hooks/use-file-explorer-actions.ts | 34 +- .../app/src/hooks/use-form-preferences.ts | 8 +- packages/app/src/hooks/use-git-actions.ts | 79 +- .../src/hooks/use-image-attachment-picker.ts | 5 +- packages/app/src/hooks/use-is-local-daemon.ts | 24 +- .../src/hooks/use-keyboard-action-handler.ts | 9 +- .../app/src/hooks/use-keyboard-shortcuts.ts | 8 +- .../app/src/hooks/use-open-project-picker.ts | 4 +- packages/app/src/hooks/use-open-project.ts | 31 +- .../src/hooks/use-push-token-registration.ts | 5 +- packages/app/src/hooks/use-recent-paths.ts | 37 +- .../app/src/hooks/use-session-directory.ts | 10 +- packages/app/src/hooks/use-settings.test.ts | 2 +- packages/app/src/hooks/use-settings.ts | 5 +- .../src/hooks/use-sidebar-agent-sections.ts | 7 +- .../src/hooks/use-sidebar-shortcut-model.ts | 78 +- .../hooks/use-sidebar-workspaces-list.test.ts | 283 +- .../src/hooks/use-sidebar-workspaces-list.ts | 342 +- packages/app/src/hooks/use-theme-color.ts | 8 +- .../app/src/hooks/use-workspace-navigation.ts | 9 +- packages/app/src/keyboard/focus-scope.ts | 5 +- .../keyboard/keyboard-action-dispatcher.ts | 6 +- .../src/keyboard/keyboard-shortcuts.test.ts | 16 +- .../app/src/keyboard/keyboard-shortcuts.ts | 42 +- packages/app/src/panels/agent-panel.tsx | 148 +- packages/app/src/panels/draft-panel.tsx | 10 +- packages/app/src/panels/file-panel.tsx | 8 +- packages/app/src/panels/panel-registry.ts | 8 +- packages/app/src/panels/terminal-panel.tsx | 2 +- .../app/src/polyfills/screen-orientation.ts | 10 +- packages/app/src/query/query-client.ts | 1 - .../activity/last-activity-scheduler.ts | 2 +- packages/app/src/runtime/activity/types.ts | 4 +- packages/app/src/runtime/host-runtime.test.ts | 79 +- packages/app/src/runtime/host-runtime.ts | 169 +- .../agent-ready-screen.bottom-anchor.test.ts | 6 +- .../src/screens/agent/draft-agent-screen.tsx | 940 +-- .../app/src/screens/open-project-screen.tsx | 5 +- packages/app/src/screens/sessions-screen.tsx | 4 +- packages/app/src/screens/settings-screen.tsx | 270 +- .../workspace/use-workspace-tab-layout.ts | 6 +- .../workspace-agent-visibility.test.ts | 6 +- .../workspace/workspace-agent-visibility.ts | 4 +- .../workspace/workspace-bulk-close.test.ts | 8 +- .../workspace/workspace-desktop-tabs-row.tsx | 41 +- .../workspace/workspace-draft-agent-tab.tsx | 148 +- .../workspace/workspace-git-actions.tsx | 29 +- .../workspace/workspace-header-source.ts | 19 +- .../workspace/workspace-pane-state.test.ts | 5 +- .../screens/workspace/workspace-pane-state.ts | 7 +- .../screens/workspace/workspace-screen.tsx | 455 +- .../workspace-source-of-truth.test.ts | 62 +- .../screens/workspace/workspace-tab-layout.ts | 12 +- .../workspace/workspace-tab-menu.test.ts | 10 +- .../screens/workspace/workspace-tab-menu.ts | 4 +- .../workspace/workspace-tab-model.test.ts | 8 +- .../screens/workspace/workspace-tab-model.ts | 4 +- .../workspace/workspace-tab-presentation.tsx | 2 +- .../stores/checkout-git-actions-store.test.ts | 5 +- .../src/stores/checkout-git-actions-store.ts | 21 +- packages/app/src/stores/create-flow-store.ts | 9 +- packages/app/src/stores/download-store.ts | 13 +- packages/app/src/stores/draft-store.ts | 45 +- .../src/stores/keyboard-shortcuts-store.ts | 38 +- .../navigation-active-workspace-store.ts | 17 +- packages/app/src/stores/panel-store.test.ts | 10 +- packages/app/src/stores/panel-store.ts | 26 +- .../app/src/stores/section-order-store.ts | 6 +- packages/app/src/stores/session-store.ts | 1467 ++-- .../sidebar-collapsed-sections-store.ts | 49 +- .../app/src/stores/sidebar-order-store.ts | 78 +- .../src/stores/workspace-layout-actions.ts | 109 +- .../src/stores/workspace-layout-store.test.ts | 62 +- .../app/src/stores/workspace-layout-store.ts | 17 +- .../src/stores/workspace-tabs-store.test.ts | 17 +- .../app/src/stores/workspace-tabs-store.ts | 52 +- packages/app/src/styles/theme.ts | 24 +- ...se-workspace-terminal-session-retention.ts | 4 +- .../runtime/terminal-emulator-runtime.ts | 28 +- .../runtime/terminal-output-delivery-queue.ts | 3 +- .../runtime/terminal-output-pump.test.ts | 4 +- .../runtime/terminal-output-session.ts | 5 +- .../terminal-stream-controller.test.ts | 8 +- .../runtime/terminal-stream-controller.ts | 46 +- packages/app/src/test/window-local-storage.ts | 2 +- packages/app/src/types/agent-activity.ts | 152 +- packages/app/src/types/host-connection.ts | 291 +- packages/app/src/types/stream.harness.test.ts | 7 +- packages/app/src/types/stream.test.ts | 66 +- packages/app/src/types/stream.ts | 137 +- .../app/src/utils/agent-attention.test.ts | 18 +- packages/app/src/utils/agent-attention.ts | 9 +- .../app/src/utils/agent-directory-sync.ts | 5 +- packages/app/src/utils/agent-display-info.ts | 9 +- packages/app/src/utils/agent-grouping.test.ts | 21 +- packages/app/src/utils/agent-grouping.ts | 37 +- .../app/src/utils/agent-initialization.ts | 5 +- packages/app/src/utils/agent-snapshots.ts | 7 +- ...gent-working-directory-suggestions.test.ts | 5 +- .../agent-working-directory-suggestions.ts | 2 +- packages/app/src/utils/analytics.ts | 6 +- packages/app/src/utils/app-visibility.ts | 7 +- packages/app/src/utils/attempt-guard.ts | 1 - packages/app/src/utils/client-id.test.ts | 6 +- .../src/utils/command-center-focus-restore.ts | 1 - packages/app/src/utils/confirm-dialog.test.ts | 20 +- packages/app/src/utils/confirm-dialog.ts | 2 +- .../src/utils/connection-selection.test.ts | 10 +- .../app/src/utils/connection-selection.ts | 4 +- packages/app/src/utils/daemon-endpoints.ts | 5 +- packages/app/src/utils/daemons.ts | 4 +- .../desktop-window-drag-coordinates.test.ts | 40 +- .../utils/desktop-window-drag-coordinates.ts | 18 +- packages/app/src/utils/desktop-window.test.ts | 44 +- packages/app/src/utils/desktop-window.ts | 180 +- .../app/src/utils/diff-highlighter.test.ts | 52 +- packages/app/src/utils/diff-highlighter.ts | 238 +- packages/app/src/utils/encode-images.ts | 2 +- packages/app/src/utils/explorer-paths.test.ts | 10 +- packages/app/src/utils/explorer-paths.ts | 4 +- .../utils/file-mention-autocomplete.test.ts | 80 +- .../src/utils/file-mention-autocomplete.ts | 42 +- .../app/src/utils/format-shortcut.test.ts | 1 - packages/app/src/utils/format-shortcut.ts | 13 +- .../app/src/utils/git-diff-scroll.test.ts | 10 +- packages/app/src/utils/host-routes.test.ts | 14 +- packages/app/src/utils/host-routes.ts | 35 +- .../image-attachments-from-files.test.ts | 6 +- .../src/utils/image-attachments-from-files.ts | 10 +- packages/app/src/utils/inline-path.test.ts | 48 +- packages/app/src/utils/inline-path.ts | 20 +- packages/app/src/utils/markdown-list.test.ts | 29 +- packages/app/src/utils/markdown-list.ts | 8 +- .../app/src/utils/new-agent-routing.test.ts | 24 +- packages/app/src/utils/new-agent-routing.ts | 14 +- .../src/utils/notification-routing.test.ts | 13 +- .../app/src/utils/notification-routing.ts | 9 +- .../app/src/utils/os-notifications.test.ts | 20 +- packages/app/src/utils/os-notifications.ts | 32 +- .../src/utils/pending-create-images.test.ts | 12 +- .../app/src/utils/pending-create-images.ts | 2 +- .../src/utils/project-display-name.test.ts | 2 +- .../app/src/utils/project-display-name.ts | 4 +- .../app/src/utils/project-placement.test.ts | 13 +- .../src/utils/provider-command-templates.ts | 6 +- .../src/utils/scroll-jank-investigation.ts | 44 +- packages/app/src/utils/scroll-jank.ts | 2 +- .../utils/server-info-capabilities.test.ts | 10 +- packages/app/src/utils/shortcut-platform.ts | 3 +- .../app/src/utils/sidebar-agent-state.test.ts | 8 +- packages/app/src/utils/sidebar-agent-state.ts | 14 +- .../utils/sidebar-gesture-arbitration.test.ts | 20 +- .../utils/sidebar-project-row-model.test.ts | 153 +- .../src/utils/sidebar-project-row-model.ts | 48 +- .../app/src/utils/sidebar-reorder.test.ts | 11 +- packages/app/src/utils/sidebar-reorder.ts | 23 +- .../app/src/utils/sidebar-shortcuts.test.ts | 9 +- packages/app/src/utils/sidebar-shortcuts.ts | 48 +- packages/app/src/utils/split-navigation.ts | 8 +- packages/app/src/utils/status-loader.ts | 7 +- .../app/src/utils/terminal-attach.test.ts | 30 +- packages/app/src/utils/terminal-attach.ts | 15 +- packages/app/src/utils/terminal-keys.test.ts | 22 +- packages/app/src/utils/terminal-keys.ts | 20 +- packages/app/src/utils/terminal-list.ts | 9 +- .../app/src/utils/terminal-output-buffer.ts | 4 +- .../src/utils/test-daemon-connection.test.ts | 2 +- .../app/src/utils/test-daemon-connection.ts | 77 +- .../app/src/utils/thinking-tone.native-pcm.ts | 5 +- packages/app/src/utils/to-xterm-theme.ts | 1 - .../src/utils/tool-call-detail-state.test.ts | 9 +- .../app/src/utils/tool-call-detail-state.ts | 23 +- packages/app/src/utils/tool-call-icon.ts | 5 +- packages/app/src/utils/tool-call-parsers.ts | 20 +- packages/app/src/utils/web-focus.ts | 1 - .../working-directory-suggestions.test.ts | 20 +- .../utils/working-directory-suggestions.ts | 2 +- .../app/src/utils/working-indicator.test.ts | 11 +- packages/app/src/utils/working-indicator.ts | 5 +- .../workspace-archive-navigation.test.ts | 8 +- .../src/utils/workspace-archive-navigation.ts | 5 +- .../app/src/utils/workspace-tab-identity.ts | 4 +- packages/app/src/voice/audio-engine.native.ts | 26 +- packages/app/src/voice/audio-engine.web.ts | 29 +- packages/app/src/voice/voice-runtime.test.ts | 18 +- packages/app/src/voice/voice-runtime.ts | 153 +- packages/app/vitest.config.ts | 6 +- packages/cli/src/cli.ts | 291 +- packages/cli/src/commands/agent/archive.ts | 117 +- packages/cli/src/commands/agent/attach.ts | 180 +- packages/cli/src/commands/agent/delete.ts | 119 +- packages/cli/src/commands/agent/index.ts | 218 +- packages/cli/src/commands/agent/inspect.ts | 253 +- packages/cli/src/commands/agent/logs.ts | 201 +- packages/cli/src/commands/agent/ls.ts | 212 +- packages/cli/src/commands/agent/mode.ts | 110 +- packages/cli/src/commands/agent/run.ts | 464 +- packages/cli/src/commands/agent/send.ts | 243 +- packages/cli/src/commands/agent/stop.ts | 117 +- packages/cli/src/commands/agent/update.ts | 165 +- packages/cli/src/commands/agent/wait.ts | 173 +- packages/cli/src/commands/daemon/index.ts | 77 +- .../cli/src/commands/daemon/local-daemon.ts | 461 +- packages/cli/src/commands/daemon/pair.ts | 49 +- packages/cli/src/commands/daemon/restart.ts | 118 +- .../src/commands/daemon/runtime-toolchain.ts | 33 +- packages/cli/src/commands/daemon/start.ts | 60 +- packages/cli/src/commands/daemon/status.ts | 278 +- packages/cli/src/commands/daemon/stop.ts | 77 +- packages/cli/src/commands/onboard.ts | 402 +- packages/cli/src/commands/permit/allow.ts | 148 +- packages/cli/src/commands/permit/deny.ts | 122 +- packages/cli/src/commands/permit/index.ts | 56 +- packages/cli/src/commands/permit/ls.ts | 88 +- packages/cli/src/commands/provider/index.ts | 34 +- packages/cli/src/commands/provider/ls.ts | 68 +- packages/cli/src/commands/provider/models.ts | 73 +- packages/cli/src/commands/speech/download.ts | 9 +- packages/cli/src/commands/speech/index.ts | 32 +- packages/cli/src/commands/speech/models.ts | 9 +- packages/cli/src/commands/worktree/archive.ts | 113 +- packages/cli/src/commands/worktree/index.ts | 32 +- packages/cli/src/commands/worktree/ls.ts | 110 +- packages/cli/src/index.ts | 8 +- packages/cli/src/output/index.ts | 14 +- packages/cli/src/output/json.ts | 31 +- packages/cli/src/output/quiet.ts | 19 +- packages/cli/src/output/render.ts | 83 +- packages/cli/src/output/table.ts | 134 +- packages/cli/src/output/types.ts | 48 +- packages/cli/src/output/with-output.ts | 64 +- packages/cli/src/output/yaml.ts | 33 +- packages/cli/src/utils/client-id.ts | 6 +- packages/cli/src/utils/client.ts | 268 +- packages/cli/src/utils/command-options.ts | 18 +- packages/cli/src/utils/duration.ts | 40 +- packages/cli/src/utils/errors.ts | 4 +- packages/cli/src/utils/timeline.ts | 18 +- packages/cli/tests/01-foundation.test.ts | 52 +- packages/cli/tests/02-output.test.ts | 344 +- packages/cli/tests/03-daemon.test.ts | 136 +- packages/cli/tests/04-agent-ls.test.ts | 158 +- packages/cli/tests/05-agent-run.test.ts | 259 +- packages/cli/tests/06-agent-send.test.ts | 223 +- packages/cli/tests/07-agent-stop.test.ts | 134 +- packages/cli/tests/08-agent-logs.test.ts | 149 +- packages/cli/tests/09-agent-inspect.test.ts | 161 +- packages/cli/tests/10-agent-mode.test.ts | 138 +- packages/cli/tests/11-agent-archive.test.ts | 118 +- packages/cli/tests/11-agent-wait.test.ts | 188 +- packages/cli/tests/12-permit-ls.test.ts | 92 +- .../cli/tests/13-permit-allow-deny.test.ts | 168 +- packages/cli/tests/14-worktree.test.ts | 168 +- packages/cli/tests/15-provider.test.ts | 303 +- packages/cli/tests/16-agent-update.test.ts | 119 +- packages/cli/tests/17-onboard.test.ts | 106 +- .../cli/tests/18-local-daemon-utils.test.ts | 46 +- packages/cli/tests/19-errors-utils.test.ts | 24 +- .../cli/tests/20-worktree-ls-paths.test.ts | 38 +- .../tests/21-run-output-schema-helper.test.ts | 112 +- .../tests/22-daemon-stop-supervisor.test.ts | 250 +- .../tests/23-daemon-sigint-supervisor.test.ts | 225 +- .../tests/24-daemon-stop-ownership.test.ts | 102 +- .../25-daemon-restart-supervisor.test.ts | 252 +- .../26-daemon-restart-unsupervised.test.ts | 241 +- packages/cli/tests/27-agent-delete.test.ts | 144 +- .../cli/tests/28-client-ipc-targets.test.ts | 124 +- .../cli/tests/29-agent-id-resolution.test.ts | 46 +- .../cli/tests/e2e/agent-lifecycle.test.ts | 266 +- packages/cli/tests/e2e/agent-send.test.ts | 194 +- packages/cli/tests/e2e/permissions.test.ts | 265 +- .../cli/tests/e2e/run-output-schema.test.ts | 145 +- packages/cli/tests/helpers/local-cli.ts | 10 +- packages/cli/tests/helpers/network.ts | 34 +- packages/cli/tests/helpers/test-daemon.ts | 349 +- packages/cli/tests/run-all.ts | 173 +- packages/cli/tests/setup.ts | 132 +- packages/cli/tests/tmp/opencode-simple.ts | 37 +- .../tests/tmp/opencode-structured-debug.ts | 65 +- .../desktop/scripts/verify-electron-cdp.mjs | 50 +- packages/desktop/src/daemon/daemon-manager.ts | 40 +- .../desktop/src/daemon/local-transport.ts | 11 +- packages/desktop/src/daemon/runtime-paths.ts | 9 +- packages/desktop/src/features/attachments.ts | 6 +- packages/desktop/src/features/auto-updater.ts | 4 +- packages/desktop/src/features/dialogs.ts | 3 +- packages/desktop/src/features/menu.ts | 2 +- packages/desktop/src/main.ts | 29 +- packages/desktop/src/preload.ts | 3 +- .../window/window-drag-coordinates.test.ts | 68 +- .../src/window/window-drag-coordinates.ts | 56 +- packages/desktop/src/window/window-manager.ts | 124 +- packages/expo-two-way-audio/biome.json | 28 - .../highlight/src/__tests__/colors.test.ts | 98 +- .../src/__tests__/highlighter.test.ts | 186 +- .../highlight/src/__tests__/parsers.test.ts | 124 +- packages/highlight/src/colors.ts | 86 +- packages/highlight/src/highlighter.ts | 138 +- packages/highlight/src/index.ts | 8 +- packages/highlight/src/parsers.ts | 48 +- packages/highlight/src/types.ts | 44 +- packages/relay/src/cloudflare-adapter.test.ts | 22 +- packages/relay/src/cloudflare-adapter.ts | 48 +- packages/relay/src/crypto.test.ts | 47 +- packages/relay/src/crypto.ts | 5 +- packages/relay/src/e2e.test.ts | 117 +- packages/relay/src/encrypted-channel.test.ts | 67 +- packages/relay/src/encrypted-channel.ts | 32 +- packages/relay/src/live-relay.e2e.test.ts | 82 +- packages/server/scripts/daemon-runner.ts | 2 +- packages/server/scripts/dev-runner.ts | 2 +- .../scripts/dictation-generate-baseline.ts | 8 +- .../server/scripts/download-speech-models.ts | 6 +- .../server/scripts/generate-config-schema.ts | 5 +- .../scripts/generate-sherpa-tts-matrix.ts | 18 +- .../server/scripts/mcp-echo-test-server.mjs | 10 +- .../server/scripts/process-conversation.ts | 9 +- .../server/scripts/supervision-parity.test.ts | 22 +- .../supervisor.lifecycle-intents.test.ts | 22 +- .../server/scripts/test-codex-mcp-servers.ts | 50 +- .../server/scripts/transcribe-local-wav.ts | 10 +- .../server/scripts/voice-main-daemon-e2e.ts | 14 +- .../server/scripts/voice-mcp-agent-smoke.ts | 11 +- .../server/scripts/voice-roundtrip-debug.ts | 16 +- .../daemon-client-relay-e2ee-transport.ts | 11 +- .../daemon-client-terminal-stream-manager.ts | 21 +- .../client/daemon-client-transport-types.ts | 2 +- .../client/daemon-client-transport-utils.ts | 2 +- .../client/daemon-client-transport.test.ts | 2 +- .../daemon-client-websocket-transport.ts | 8 +- .../server/src/client/daemon-client.test.ts | 2166 +++--- packages/server/src/client/daemon-client.ts | 2594 +++---- .../investigate-command-output.ts | 13 +- packages/server/src/poc-commands/run-poc.ts | 9 +- .../src/server/agent-attention-policy.test.ts | 24 +- .../src/server/agent-attention-policy.ts | 13 +- .../src/server/agent/activity-curator.test.ts | 17 +- .../src/server/agent/activity-curator.ts | 13 +- .../src/server/agent/agent-management-mcp.ts | 161 +- .../src/server/agent/agent-manager.test.ts | 164 +- .../server/src/server/agent/agent-manager.ts | 373 +- .../src/server/agent/agent-mcp.e2e.test.ts | 357 +- .../agent/agent-metadata-generator.test.ts | 108 +- .../server/agent/agent-metadata-generator.ts | 43 +- .../agent-metadata-generator.unit.test.ts | 35 +- .../server/agent/agent-projections.test.ts | 47 +- .../src/server/agent/agent-projections.ts | 59 +- .../agent/agent-response-loop.e2e.test.ts | 4 +- .../server/agent/agent-response-loop.test.ts | 36 +- .../src/server/agent/agent-response-loop.ts | 52 +- .../src/server/agent/agent-sdk-types.ts | 10 +- .../src/server/agent/agent-storage.test.ts | 89 +- .../server/src/server/agent/agent-storage.ts | 32 +- .../src/server/agent/dictation-debug.ts | 9 +- .../src/server/agent/mcp-server.test.ts | 18 +- .../server/src/server/agent/mcp-server.ts | 236 +- .../server/agent/model-catalog.e2e.test.ts | 33 +- .../src/server/agent/model-resolver.test.ts | 6 +- .../server/src/server/agent/model-resolver.ts | 4 +- .../agent/opencode-reasoning.e2e.test.ts | 177 +- .../src/server/agent/pcm16-resampler.ts | 7 +- .../agent/provider-launch-config.test.ts | 16 +- .../server/agent/provider-launch-config.ts | 26 +- .../src/server/agent/provider-manifest.ts | 91 +- .../src/server/agent/provider-registry.ts | 33 +- .../claude-agent-commands.e2e.test.ts | 5 +- .../claude-agent-commands.real.e2e.test.ts | 58 +- .../claude-agent.integration.test.ts | 480 +- ...agent.interrupt-restart-regression.test.ts | 12 +- .../claude-agent.model-normalization.test.ts | 2 +- .../providers/claude-agent.redesign.test.ts | 136 +- .../claude-agent.sub-agent-sidechain.test.ts | 18 +- .../agent/providers/claude-agent.test.ts | 56 +- .../server/agent/providers/claude-agent.ts | 576 +- ...ude-agent.voice-history-regression.test.ts | 33 +- .../providers/claude-sdk-behavior.test.ts | 6 +- .../agent/providers/claude/model-catalog.ts | 2 +- .../providers/claude/partial-json.test.ts | 20 +- .../agent/providers/claude/partial-json.ts | 41 +- .../providers/claude/sidechain-tracker.ts | 36 +- .../task-notification-tool-call.test.ts | 4 +- .../claude/task-notification-tool-call.ts | 46 +- .../claude/tool-call-detail-parser.ts | 126 +- .../providers/claude/tool-call-mapper.test.ts | 40 +- .../providers/claude/tool-call-mapper.ts | 28 +- .../codex-app-server-agent.e2e.test.ts | 34 +- .../providers/codex-app-server-agent.test.ts | 2 +- .../agent/providers/codex-app-server-agent.ts | 1348 ++-- .../codex-mcp-agent-commands.e2e.test.ts | 54 +- .../providers/codex-rollout-parsing.test.ts | 3 +- .../agent/providers/codex-rollout-timeline.ts | 295 +- .../codex/tool-call-detail-parser.ts | 73 +- .../providers/codex/tool-call-mapper.test.ts | 60 +- .../agent/providers/codex/tool-call-mapper.ts | 72 +- .../agent/providers/opencode-agent.test.ts | 246 +- .../server/agent/providers/opencode-agent.ts | 114 +- .../opencode/event-translator.test.ts | 27 +- .../opencode/tool-call-detail-parser.ts | 62 +- .../opencode/tool-call-mapper.test.ts | 28 +- .../providers/opencode/tool-call-mapper.ts | 11 +- .../providers/tool-call-detail-primitives.ts | 173 +- .../agent/providers/tool-call-mapper-utils.ts | 10 +- .../src/server/agent/recordings-debug.ts | 1 - packages/server/src/server/agent/stt-debug.ts | 2 +- .../src/server/agent/stt-manager.test.ts | 6 +- .../server/src/server/agent/stt-manager.ts | 25 +- .../server/src/server/agent/system-prompt.ts | 10 +- .../src/server/agent/timeline-append.ts | 4 +- .../src/server/agent/timeline-projection.ts | 17 +- packages/server/src/server/agent/tts-debug.ts | 2 +- .../src/server/agent/tts-manager.test.ts | 24 +- .../server/src/server/agent/tts-manager.ts | 66 +- .../server/agent/wait-for-agent-tracker.ts | 5 +- .../server/src/server/allowed-hosts.test.ts | 1 - packages/server/src/server/allowed-hosts.ts | 15 +- .../server/src/server/bootstrap.smoke.test.ts | 24 +- packages/server/src/server/bootstrap.ts | 867 ++- .../src/server/client-activity.e2e.test.ts | 62 +- .../server/src/server/client-message-id.ts | 2 +- packages/server/src/server/config.ts | 32 +- .../server/src/server/connection-offer.ts | 13 +- .../src/server/daemon-client.e2e.test.ts | 1097 ++- .../daemon-e2e/agent-basics.e2e.test.ts | 36 +- .../daemon-e2e/agent-operations.e2e.test.ts | 608 +- .../src/server/daemon-e2e/checkout-debug.ts | 14 +- .../checkout-diff-subscription.e2e.test.ts | 134 +- .../daemon-e2e/checkout-ship.e2e.test.ts | 282 +- ...de-autonomous-wake-simple.real.e2e.test.ts | 28 +- .../claude-autonomous-wake.real.e2e.test.ts | 122 +- .../daemon-e2e/connection-offer.e2e.test.ts | 146 +- .../daemon-restart-resume.e2e.test.ts | 75 +- .../daemon-e2e/file-download.e2e.test.ts | 180 +- .../server/daemon-e2e/filesystem.e2e.test.ts | 199 +- .../git-diff-bottleneck.local.e2e.test.ts | 92 +- .../daemon-e2e/git-operations.e2e.test.ts | 1099 ++- .../src/server/daemon-e2e/images.e2e.test.ts | 216 +- .../daemon-e2e/live-preferences.e2e.test.ts | 168 +- ...-runtime-reconcile-claude.real.e2e.test.ts | 2 +- .../src/server/daemon-e2e/models.e2e.test.ts | 57 +- .../daemon-e2e/permissions-claude.e2e.test.ts | 175 +- .../daemon-e2e/permissions-codex.e2e.test.ts | 558 +- .../server/daemon-e2e/persistence.e2e.test.ts | 170 +- .../daemon-e2e/relay-transport.e2e.test.ts | 425 +- ...ser-message-dedupe-claude.real.e2e.test.ts | 6 +- ...hile-running-stuck-claude.real.e2e.test.ts | 12 +- .../send-while-running-stuck-test-utils.ts | 6 +- .../send-while-running-stuck.real.e2e.test.ts | 10 +- .../server/src/server/daemon-e2e/setup.ts | 5 +- .../server/daemon-e2e/streaming.e2e.test.ts | 107 +- .../server/daemon-e2e/terminal.e2e.test.ts | 1244 ++-- .../daemon-e2e/timeline-window.e2e.test.ts | 122 +- .../server/daemon-e2e/tool-calls.e2e.test.ts | 442 +- .../daemon-e2e/two-cycle-resume.e2e.test.ts | 96 +- .../ui-action-stress.real.e2e.test.ts | 209 +- .../daemon-e2e/wait-for-idle.e2e.test.ts | 21 +- packages/server/src/server/daemon-keypair.ts | 3 +- .../server/src/server/daemon-version.test.ts | 19 +- packages/server/src/server/daemon-version.ts | 5 +- .../dictation-stream-manager.test.ts | 20 +- .../dictation/dictation-stream-manager.ts | 81 +- packages/server/src/server/exports.ts | 7 +- .../src/server/file-explorer/service.ts | 36 +- packages/server/src/server/index.ts | 10 +- packages/server/src/server/logger.ts | 15 +- packages/server/src/server/messages.ts | 15 +- packages/server/src/server/package-version.ts | 9 +- .../server/src/server/persisted-config.ts | 7 +- .../src/server/persistence-hooks.test.ts | 50 +- .../server/src/server/persistence-hooks.ts | 24 +- packages/server/src/server/pid-lock.test.ts | 62 +- packages/server/src/server/pid-lock.ts | 16 +- .../server/src/server/push/push-service.ts | 9 +- .../server/src/server/relay-transport.test.ts | 13 +- packages/server/src/server/relay-transport.ts | 33 +- packages/server/src/server/server-id.test.ts | 1 - packages/server/src/server/server-id.ts | 3 +- .../server/session.create-agent-title.test.ts | 48 +- .../server/session.lifecycle-boundary.test.ts | 16 +- packages/server/src/server/session.ts | 6029 +++++++++-------- .../server/session.voice-mcp-config.test.ts | 4 +- .../session.workspace-git-watch.test.ts | 264 +- .../src/server/session.workspaces.test.ts | 819 +-- packages/server/src/server/speech/audio.ts | 16 +- .../server/speech/providers/local/config.ts | 60 +- .../server/speech/providers/local/models.ts | 5 +- .../providers/local/pocket/pocket-tts-onnx.ts | 81 +- .../server/speech/providers/local/runtime.ts | 62 +- .../providers/local/sherpa/model-catalog.ts | 13 +- .../local/sherpa/model-downloader.test.ts | 4 +- .../local/sherpa/model-downloader.ts | 16 +- .../local/sherpa/sherpa-offline-recognizer.ts | 15 +- .../local/sherpa/sherpa-online-recognizer.ts | 11 +- .../local/sherpa/sherpa-onnx-node-loader.ts | 2 +- .../sherpa-parakeet-realtime-session.ts | 9 +- .../local/sherpa/sherpa-parakeet-stt.ts | 11 +- .../local/sherpa/sherpa-realtime-session.ts | 9 +- .../local/sherpa/sherpa-runtime-env.ts | 11 +- .../providers/local/sherpa/sherpa-stt.ts | 16 +- .../providers/local/sherpa/sherpa-tts.ts | 10 +- .../local/sherpa/silero-vad-provider.ts | 7 +- .../local/sherpa/silero-vad-session.ts | 15 +- .../local/sherpa/speech-download.e2e.test.ts | 45 +- .../server/speech/providers/openai/config.ts | 31 +- .../openai/realtime-transcription-session.ts | 11 +- .../server/speech/providers/openai/runtime.ts | 20 +- .../src/server/speech/providers/openai/stt.ts | 20 +- .../src/server/speech/providers/openai/tts.ts | 12 +- .../server/speech/speech-config-resolver.ts | 24 +- .../src/server/speech/speech-provider.ts | 10 +- .../src/server/speech/speech-runtime.test.ts | 8 +- .../src/server/speech/speech-runtime.ts | 42 +- .../server/speech/turn-detection-provider.ts | 4 +- .../server/src/server/terminal-mcp/server.ts | 52 +- .../terminal-mcp/terminal-manager.test.ts | 161 +- .../server/terminal-mcp/terminal-manager.ts | 67 +- .../server/src/server/terminal-mcp/tmux.ts | 210 +- .../src/server/test-utils/claude-auth.ts | 8 +- .../server/test-utils/daemon-test-context.ts | 2 +- .../src/server/test-utils/dictation-e2e.ts | 16 +- .../server/test-utils/fake-agent-client.ts | 121 +- .../src/server/test-utils/paseo-daemon.ts | 17 +- .../src/server/utils/diff-highlighter.test.ts | 52 +- .../src/server/utils/diff-highlighter.ts | 280 +- packages/server/src/server/voice-config.ts | 8 +- .../src/server/voice-local-agent.e2e.test.ts | 245 +- .../server/voice-mcp-bridge-command.test.ts | 6 +- .../src/server/voice-mcp-bridge-command.ts | 4 +- .../src/server/voice-mcp-bridge.test.ts | 6 +- .../server/src/server/voice-mcp-bridge.ts | 7 +- .../server/voice-permission-policy.test.ts | 26 +- .../src/server/voice-roundtrip.e2e.test.ts | 389 +- .../voice/fixed-duration-pcm-ring-buffer.ts | 8 +- .../voice/voice-turn-controller.test.ts | 21 +- .../src/server/voice/voice-turn-controller.ts | 20 +- .../websocket-server.notifications.test.ts | 25 +- .../websocket-server.relay-reconnect.test.ts | 40 +- .../server/src/server/websocket-server.ts | 151 +- .../workspace-registry-bootstrap.test.ts | 206 +- .../server/workspace-registry-bootstrap.ts | 98 +- .../server/workspace-registry-model.test.ts | 73 +- .../src/server/workspace-registry-model.ts | 184 +- .../src/server/workspace-registry.test.ts | 152 +- .../server/src/server/workspace-registry.ts | 210 +- .../src/server/worktree-bootstrap.test.ts | 52 +- .../server/src/server/worktree-bootstrap.ts | 48 +- .../agent-attention-notification.test.ts | 15 +- .../shared/agent-attention-notification.ts | 15 +- packages/server/src/shared/agent-lifecycle.ts | 3 +- .../src/shared/daemon-endpoints.test.ts | 10 +- .../server/src/shared/daemon-endpoints.ts | 11 +- .../shared/messages.stream-parsing.test.ts | 276 +- packages/server/src/shared/messages.ts | 1304 ++-- .../src/shared/messages.workspaces.test.ts | 73 +- .../server/src/shared/tool-call-display.ts | 4 +- packages/server/src/tasks/cli.ts | 105 +- .../server/src/tasks/execution-order.test.ts | 42 +- packages/server/src/tasks/execution-order.ts | 16 +- packages/server/src/tasks/task-store.test.ts | 45 +- packages/server/src/tasks/task-store.ts | 27 +- packages/server/src/tasks/types.ts | 6 +- .../src/terminal/terminal-manager.test.ts | 6 +- .../server/src/terminal/terminal-manager.ts | 4 +- packages/server/src/terminal/terminal.test.ts | 83 +- packages/server/src/terminal/terminal.ts | 20 +- .../server/src/utils/checkout-git.test.ts | 84 +- packages/server/src/utils/checkout-git.ts | 147 +- .../src/utils/directory-suggestions.test.ts | 306 +- .../server/src/utils/directory-suggestions.ts | 730 +- .../server/src/utils/project-icon.test.ts | 134 +- packages/server/src/utils/project-icon.ts | 40 +- .../src/utils/tool-call-parsers.test.ts | 8 +- .../server/src/utils/tool-call-parsers.ts | 5 +- .../server/src/utils/worktree-metadata.ts | 4 +- packages/server/src/utils/worktree.test.ts | 84 +- packages/server/src/utils/worktree.ts | 81 +- packages/server/tsconfig.scripts.json | 6 +- .../public/schemas/paseo.config.v1.json | 53 +- packages/website/src/components/butterfly.tsx | 291 +- .../website/src/components/landing-page.tsx | 764 ++- packages/website/src/meta.ts | 8 +- packages/website/src/routeTree.gen.ts | 568 +- packages/website/src/router.tsx | 10 +- packages/website/src/routes/__root.tsx | 37 +- packages/website/src/routes/changelog.tsx | 20 +- packages/website/src/routes/claude-code.tsx | 16 +- packages/website/src/routes/codex.tsx | 16 +- packages/website/src/routes/docs.tsx | 32 +- .../src/routes/docs/best-practices.tsx | 119 +- packages/website/src/routes/docs/cli.tsx | 48 +- .../website/src/routes/docs/configuration.tsx | 168 +- packages/website/src/routes/docs/index.tsx | 34 +- packages/website/src/routes/docs/security.tsx | 42 +- packages/website/src/routes/docs/updates.tsx | 16 +- packages/website/src/routes/docs/voice.tsx | 91 +- .../website/src/routes/docs/worktrees.tsx | 124 +- packages/website/src/routes/index.tsx | 23 +- packages/website/src/routes/opencode.tsx | 16 +- packages/website/src/routes/privacy.tsx | 53 +- packages/website/src/styles.css | 2 +- packages/website/src/types/raw-modules.d.ts | 6 +- packages/website/vite.config.ts | 20 +- scripts/measure-relay-latency.ts | 7 +- scripts/prove-relay-prod.mjs | 60 +- scripts/push-current-release-tag.mjs | 2 +- scripts/sync-release-notes-from-changelog.mjs | 30 +- scripts/sync-workspace-versions.mjs | 72 +- 805 files changed, 38132 insertions(+), 40175 deletions(-) delete mode 100644 .prettierignore delete mode 100644 .prettierrc create mode 100644 biome.json delete mode 100644 packages/expo-two-way-audio/biome.json diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 2c950c2ef..000000000 --- a/.prettierignore +++ /dev/null @@ -1,21 +0,0 @@ -# Dependencies -node_modules - -# Build outputs -dist -.next -.expo -build -*.tsbuildinfo - -# Coverage -coverage - -# Lock files -*.lock -package-lock.json - -# Generated -android -ios -.turbo diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 989e171c0..000000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": false, - "singleQuote": true, - "trailingComma": "es5", - "tabWidth": 2, - "printWidth": 100 -} diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..20c8b8f0c --- /dev/null +++ b/biome.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**", "!*.lock"] + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + }, + "css": { + "parser": { + "cssModules": true, + "tailwindDirectives": true + } + }, + "linter": { + "enabled": false + } +} diff --git a/package-lock.json b/package-lock.json index 59b019386..e9afdf757 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,10 +24,10 @@ "@modelcontextprotocol/sdk": "^1.27.1" }, "devDependencies": { + "@biomejs/biome": "^2.4.8", "get-port-cli": "^3.0.0", "knip": "^5.82.1", "patch-package": "^8.0.1", - "prettier": "^3.5.3", "react": "19.1.4", "react-dom": "19.1.4", "typescript": "^5.9.3" @@ -2559,11 +2559,10 @@ } }, "node_modules/@biomejs/biome": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", - "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.8.tgz", + "integrity": "sha512-ponn0oKOky1oRXBV+rlSaUlixUxf1aZvWC19Z41zBfUOUesthrQqL3OtiAlSB1EjFjyWpn98Q64DHelhA6jNlA==", "dev": true, - "hasInstallScript": true, "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" @@ -2576,20 +2575,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "1.9.4", - "@biomejs/cli-darwin-x64": "1.9.4", - "@biomejs/cli-linux-arm64": "1.9.4", - "@biomejs/cli-linux-arm64-musl": "1.9.4", - "@biomejs/cli-linux-x64": "1.9.4", - "@biomejs/cli-linux-x64-musl": "1.9.4", - "@biomejs/cli-win32-arm64": "1.9.4", - "@biomejs/cli-win32-x64": "1.9.4" + "@biomejs/cli-darwin-arm64": "2.4.8", + "@biomejs/cli-darwin-x64": "2.4.8", + "@biomejs/cli-linux-arm64": "2.4.8", + "@biomejs/cli-linux-arm64-musl": "2.4.8", + "@biomejs/cli-linux-x64": "2.4.8", + "@biomejs/cli-linux-x64-musl": "2.4.8", + "@biomejs/cli-win32-arm64": "2.4.8", + "@biomejs/cli-win32-x64": "2.4.8" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", - "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.8.tgz", + "integrity": "sha512-ARx0tECE8I7S2C2yjnWYLNbBdDoPdq3oyNLhMglmuctThwUsuzFWRKrHmIGwIRWKz0Mat9DuzLEDp52hGnrxGQ==", "cpu": [ "arm64" ], @@ -2604,9 +2603,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", - "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.8.tgz", + "integrity": "sha512-Jg9/PsB9vDCJlANE8uhG7qDhb5w0Ix69D7XIIc8IfZPUoiPrbLm33k2Ig3NOJ/7nb3UbesFz3D1aDKm9DvzjhQ==", "cpu": [ "x64" ], @@ -2621,9 +2620,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", - "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.8.tgz", + "integrity": "sha512-5CdrsJct76XG2hpKFwXnEtlT1p+4g4yV+XvvwBpzKsTNLO9c6iLlAxwcae2BJ7ekPGWjNGw9j09T5KGPKKxQig==", "cpu": [ "arm64" ], @@ -2638,9 +2637,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", - "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.8.tgz", + "integrity": "sha512-Zo9OhBQDJ3IBGPlqHiTISloo5H0+FBIpemqIJdW/0edJ+gEcLR+MZeZozcUyz3o1nXkVA7++DdRKQT0599j9jA==", "cpu": [ "arm64" ], @@ -2655,9 +2654,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", - "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.8.tgz", + "integrity": "sha512-PdKXspVEaMCQLjtZCn6vfSck/li4KX9KGwSDbZdgIqlrizJ2MnMcE3TvHa2tVfXNmbjMikzcfJpuPWH695yJrw==", "cpu": [ "x64" ], @@ -2672,9 +2671,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", - "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.8.tgz", + "integrity": "sha512-Gi8quv8MEuDdKaPFtS2XjEnMqODPsRg6POT6KhoP+VrkNb+T2ywunVB+TvOU0LX1jAZzfBr+3V1mIbBhzAMKvw==", "cpu": [ "x64" ], @@ -2689,9 +2688,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", - "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.8.tgz", + "integrity": "sha512-LoFatS0tnHv6KkCVpIy3qZCih+MxUMvdYiPWLHRri7mhi2vyOOs8OrbZBcLTUEWCS+ktO72nZMy4F96oMhkOHQ==", "cpu": [ "arm64" ], @@ -2706,9 +2705,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", - "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "version": "2.4.8", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.8.tgz", + "integrity": "sha512-vAn7iXDoUbqFXqVocuq1sMYAd33p8+mmurqJkWl6CtIhobd/O6moe4rY5AJvzbunn/qZCdiDVcveqtkFh1e7Hg==", "cpu": [ "x64" ], @@ -34907,6 +34906,170 @@ "react-native": "*" } }, + "packages/expo-two-way-audio/node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "packages/expo-two-way-audio/node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "packages/expo-two-way-audio/node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", diff --git a/package.json b/package.json index e1e37ae6d..bdb7d23c4 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "typecheck": "npm run typecheck --workspaces --if-present", "typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli", "test": "npm run test --workspaces --if-present", - "format": "prettier --write .", - "format:check": "prettier --check .", + "format": "biome format --write .", + "format:check": "biome format .", "start": "npm run start --workspace=@getpaseo/server", "android": "npm run android --workspace=@getpaseo/app", "android:development": "npm run android:development --workspace=@getpaseo/app", @@ -57,10 +57,10 @@ "release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push" }, "devDependencies": { + "@biomejs/biome": "^2.4.8", "get-port-cli": "^3.0.0", "knip": "^5.82.1", "patch-package": "^8.0.1", - "prettier": "^3.5.3", "react": "19.1.4", "react-dom": "19.1.4", "typescript": "^5.9.3" diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 80d6acbb6..959fb27f0 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -65,8 +65,7 @@ export default { ios: { supportsTablet: true, infoPlist: { - NSMicrophoneUsageDescription: - "This app needs access to the microphone for voice commands.", + NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.", ITSAppUsesNonExemptEncryption: false, }, bundleIdentifier: variant.packageId, @@ -92,9 +91,7 @@ export default { "android.permission.CAMERA", ], package: variant.packageId, - ...(variant.googleServicesFile - ? { googleServicesFile: variant.googleServicesFile } - : {}), + ...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}), }, web: { output: "single", diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index a05a5e680..e9d9453cc 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,15 +1,12 @@ -import { test as base, expect, type Page } from '@playwright/test'; -import { - buildCreateAgentPreferences, - buildSeededHost, -} from './helpers/daemon-registry'; +import { test as base, expect, type Page } from "@playwright/test"; +import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry"; // Extend base test to provide dynamic baseURL from global-setup const test = base.extend({ baseURL: async ({}, use) => { const metroPort = process.env.E2E_METRO_PORT; if (!metroPort) { - throw new Error('E2E_METRO_PORT not set - globalSetup must run first'); + throw new Error("E2E_METRO_PORT not set - globalSetup must run first"); } await use(`http://localhost:${metroPort}`); }, @@ -22,19 +19,19 @@ test.beforeEach(async ({ page }) => { const metroPort = process.env.E2E_METRO_PORT; if (!daemonPort) { throw new Error( - 'E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). ' + - 'Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.' + "E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " + + "Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.", ); } - if (daemonPort === '6767') { + if (daemonPort === "6767") { throw new Error( - 'E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. ' + - 'Fix Playwright globalSetup to start an isolated test daemon and export its port.' + "E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " + + "Fix Playwright globalSetup to start an isolated test daemon and export its port.", ); } if (!metroPort) { throw new Error( - 'E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.' + "E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.", ); } @@ -42,17 +39,17 @@ test.beforeEach(async ({ page }) => { // This blocks both HTTP and WS attempts to :6767 (before any navigation). await page.route(/:(6767)\b/, (route) => route.abort()); await page.routeWebSocket(/:(6767)\b/, async (ws) => { - await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' }); + await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." }); }); const entries: string[] = []; consoleEntries.set(page, entries); - page.on('console', (message) => { + page.on("console", (message) => { entries.push(`[console:${message.type()}] ${message.text()}`); }); - page.on('pageerror', (error) => { + page.on("pageerror", (error) => { entries.push(`[pageerror] ${error.message}`); }); @@ -60,7 +57,7 @@ test.beforeEach(async ({ page }) => { const seedNonce = Math.random().toString(36).slice(2); const serverId = process.env.E2E_SERVER_ID; if (!serverId) { - throw new Error('E2E_SERVER_ID is not set - expected from Playwright globalSetup.'); + throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup."); } const testDaemon = buildSeededHost({ serverId, @@ -74,7 +71,7 @@ test.beforeEach(async ({ page }) => { // `addInitScript` runs on every navigation (including reloads). Some tests intentionally // override storage and reload; they can opt out of seeding for the *next* navigation by // setting this flag before the reload. - const disableOnceKey = '@paseo:e2e-disable-default-seed-once'; + const disableOnceKey = "@paseo:e2e-disable-default-seed-once"; const disableValue = localStorage.getItem(disableOnceKey); if (disableValue) { localStorage.removeItem(disableOnceKey); @@ -83,15 +80,15 @@ test.beforeEach(async ({ page }) => { } } - localStorage.setItem('@paseo:e2e', '1'); - localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce); + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce); // Hard-reset anything that could point to a developer's real daemon. - localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); - localStorage.removeItem('@paseo:settings'); - localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); + localStorage.removeItem("@paseo:settings"); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { daemon: testDaemon, preferences: createAgentPreferences, seedNonce } + { daemon: testDaemon, preferences: createAgentPreferences, seedNonce }, ); }); @@ -105,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => { return; } - await testInfo.attach('browser-console', { - body: entries.join('\n'), - contentType: 'text/plain', + await testInfo.attach("browser-console", { + body: entries.join("\n"), + contentType: "text/plain", }); }); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 2884a7f21..17468411e 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -1,11 +1,11 @@ -import { spawn, type ChildProcess, execSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import net from 'node:net'; -import { Buffer } from 'node:buffer'; -import dotenv from 'dotenv'; +import { spawn, type ChildProcess, execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { Buffer } from "node:buffer"; +import dotenv from "dotenv"; type WaitForServerOptions = { host?: string; @@ -18,11 +18,11 @@ type WaitForServerOptions = { async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); - server.once('error', reject); + server.once("error", reject); server.listen(0, () => { const address = server.address(); - if (!address || typeof address === 'string') { - server.close(() => reject(new Error('Failed to acquire port'))); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to acquire port"))); return; } server.close(() => resolve(address.port)); @@ -40,18 +40,18 @@ function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: } }, dump() { - return lines.join('\n'); + return lines.join("\n"); }, }; } function formatRecentOutput(getRecentOutput?: () => string): string { if (!getRecentOutput) { - return ''; + return ""; } const output = getRecentOutput().trim(); if (!output) { - return ''; + return ""; } return `\nRecent output:\n${output}`; } @@ -61,21 +61,15 @@ function sleep(ms: number): Promise { } async function waitForServer(port: number, options: WaitForServerOptions): Promise { - const { - host = '127.0.0.1', - timeoutMs = 15000, - label, - childProcess, - getRecentOutput, - } = options; + const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options; const start = Date.now(); let lastConnectionError: unknown = null; while (Date.now() - start < timeoutMs) { if (childProcess && childProcess.exitCode !== null) { - const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : ''; + const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : ""; throw new Error( - `${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}` + `${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`, ); } @@ -89,7 +83,7 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi socket.destroy(); reject(new Error(`Connection timed out to ${host}:${port}`)); }); - socket.on('error', reject); + socket.on("error", reject); }); return; } catch (error) { @@ -99,9 +93,11 @@ async function waitForServer(port: number, options: WaitForServerOptions): Promi } const reason = - lastConnectionError instanceof Error ? ` Last connection error: ${lastConnectionError.message}` : ''; + lastConnectionError instanceof Error + ? ` Last connection error: ${lastConnectionError.message}` + : ""; throw new Error( - `${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}` + `${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`, ); } @@ -126,15 +122,15 @@ async function stopProcess(child: ChildProcess | null): Promise { if (child.exitCode !== null || child.signalCode !== null) { return; } - child.kill('SIGTERM'); + child.kill("SIGTERM"); await new Promise((resolve) => { const timeout = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); + child.kill("SIGKILL"); } resolve(); }, 5000); - child.once('exit', () => { + child.once("exit", () => { clearTimeout(timeout); resolve(); }); @@ -144,7 +140,7 @@ async function stopProcess(child: ChildProcess | null): Promise { function summarizeOpenAiErrorBody(body: string): string { const trimmed = body.trim(); if (!trimmed) { - return 'empty response body'; + return "empty response body"; } if (trimmed.length <= 240) { return trimmed; @@ -159,8 +155,8 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise; - if (offer.v !== 2) throw new Error('offer.v missing/invalid'); - if (!offer.serverId) throw new Error('offer.serverId missing'); - if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing'); - if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing'); + if (offer.v !== 2) throw new Error("offer.v missing/invalid"); + if (!offer.serverId) throw new Error("offer.serverId missing"); + if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing"); + if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing"); return offer as OfferPayload; } export default async function globalSetup() { - const repoRoot = path.resolve(__dirname, '../../..'); + const repoRoot = path.resolve(__dirname, "../../.."); ensureRelayBuildArtifact(repoRoot); - const envTestPath = path.join(repoRoot, '.env.test'); + const envTestPath = path.join(repoRoot, ".env.test"); if (existsSync(envTestPath)) { dotenv.config({ path: envTestPath }); } @@ -239,13 +235,17 @@ export default async function globalSetup() { const port = await getAvailablePort(); let relayPort = 0; const metroPort = await getAvailablePort(); - paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-')); + paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")); let relayLineBuffer = createLineBuffer(); const metroLineBuffer = createLineBuffer(); const daemonLineBuffer = createLineBuffer(); const cleanup = async () => { - await Promise.all([stopProcess(daemonProcess), stopProcess(metroProcess), stopProcess(relayProcess)]); + await Promise.all([ + stopProcess(daemonProcess), + stopProcess(metroProcess), + stopProcess(relayProcess), + ]); daemonProcess = null; metroProcess = null; relayProcess = null; @@ -256,24 +256,30 @@ export default async function globalSetup() { }; const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY); - const defaultLocalModelsDir = path.join(process.env.HOME ?? '', '.paseo', 'models', 'local-speech'); - const hasDefaultLocalModelsDir = defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir); - const dictationProvider = openAiUsable ? 'openai' : 'local'; + const defaultLocalModelsDir = path.join( + process.env.HOME ?? "", + ".paseo", + "models", + "local-speech", + ); + const hasDefaultLocalModelsDir = + defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir); + const dictationProvider = openAiUsable ? "openai" : "local"; - if (dictationProvider === 'local' && !hasDefaultLocalModelsDir) { + if (dictationProvider === "local" && !hasDefaultLocalModelsDir) { throw new Error( - 'OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. ' + - 'Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.' + "OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " + + "Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.", ); } - const localModelsDir = dictationProvider === 'local' ? defaultLocalModelsDir : null; + const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null; console.log( - `[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? '' : ' (OpenAI probe failed)'}` + `[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`, ); try { - const relayDir = path.resolve(__dirname, '..', '..', 'relay'); + const relayDir = path.resolve(__dirname, "..", "..", "relay"); const maxRelayStartupAttempts = 5; let relayStarted = false; let lastRelayStartupError: unknown = null; @@ -285,18 +291,21 @@ export default async function globalSetup() { let relayReadyForSelectedPort = false; relayProcess = spawn( - 'npx', - ['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)], + "npx", + ["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)], { cwd: relayDir, env: { ...process.env }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ["ignore", "pipe", "pipe"], detached: false, - } + }, ); - relayProcess.stdout?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter((line) => line.trim()); + relayProcess.stdout?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); for (const line of lines) { relayLineBuffer.add(`[stdout] ${line}`); const failure = parseRelayStartupFailure(line); @@ -311,8 +320,11 @@ export default async function globalSetup() { console.log(`[relay] ${line}`); } }); - relayProcess.stderr?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter((line) => line.trim()); + relayProcess.stderr?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); for (const line of lines) { relayLineBuffer.add(`[stderr] ${line}`); const failure = parseRelayStartupFailure(line); @@ -330,7 +342,7 @@ export default async function globalSetup() { try { await waitForServer(relayPort, { - label: 'Relay dev server', + label: "Relay dev server", timeoutMs: 30000, childProcess: relayProcess, getRecentOutput: relayLineBuffer.dump, @@ -353,15 +365,15 @@ export default async function globalSetup() { if (!relayReadyForSelectedPort) { throw new Error( `Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput( - relayLineBuffer.dump - )}` + relayLineBuffer.dump, + )}`, ); } if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) { throw new Error( `Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput( - relayLineBuffer.dump - )}` + relayLineBuffer.dump, + )}`, ); } @@ -380,40 +392,46 @@ export default async function globalSetup() { ? lastRelayStartupError.message : String(lastRelayStartupError); throw new Error( - `Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}` + `Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`, ); } // Start Metro bundler on dynamic port - const appDir = path.resolve(__dirname, '..'); - metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], { + const appDir = path.resolve(__dirname, ".."); + metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], { cwd: appDir, env: { ...process.env, - BROWSER: 'none', // Don't auto-open browser + BROWSER: "none", // Don't auto-open browser }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ["ignore", "pipe", "pipe"], detached: false, }); - metroProcess.stdout?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter((line) => line.trim()); + metroProcess.stdout?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); for (const line of lines) { metroLineBuffer.add(`[stdout] ${line}`); console.log(`[metro] ${line}`); } }); - metroProcess.stderr?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter((line) => line.trim()); + metroProcess.stderr?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); for (const line of lines) { metroLineBuffer.add(`[stderr] ${line}`); console.error(`[metro] ${line}`); } }); - const serverDir = path.resolve(__dirname, '../../..', 'packages/server'); - const tsxBin = execSync('which tsx').toString().trim(); + const serverDir = path.resolve(__dirname, "../../..", "packages/server"); + const tsxBin = execSync("which tsx").toString().trim(); let offerPayload: OfferPayload | null = null; let offerResolve: (() => void) | null = null; @@ -421,33 +439,33 @@ export default async function globalSetup() { offerResolve = resolve; }); - daemonProcess = spawn(tsxBin, ['src/server/index.ts'], { + daemonProcess = spawn(tsxBin, ["src/server/index.ts"], { cwd: serverDir, env: { ...process.env, PASEO_HOME: paseoHome, - PASEO_SERVER_ID: 'srv_e2e_test_daemon', + PASEO_SERVER_ID: "srv_e2e_test_daemon", PASEO_LISTEN: `0.0.0.0:${port}`, PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`, PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`, // Use OpenAI speech providers in e2e to avoid local model bootstrapping delays. - PASEO_DICTATION_ENABLED: '1', - PASEO_VOICE_MODE_ENABLED: '1', + PASEO_DICTATION_ENABLED: "1", + PASEO_VOICE_MODE_ENABLED: "1", PASEO_DICTATION_STT_PROVIDER: dictationProvider, - PASEO_VOICE_STT_PROVIDER: 'openai', - PASEO_VOICE_TTS_PROVIDER: 'openai', + PASEO_VOICE_STT_PROVIDER: "openai", + PASEO_VOICE_TTS_PROVIDER: "openai", ...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}), - NODE_ENV: 'development', + NODE_ENV: "development", }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ["ignore", "pipe", "pipe"], detached: false, }); - let stdoutBuffer = ''; - daemonProcess.stdout?.on('data', (data: Buffer) => { - stdoutBuffer += data.toString('utf8'); - const lines = stdoutBuffer.split('\n'); - stdoutBuffer = lines.pop() ?? ''; + let stdoutBuffer = ""; + daemonProcess.stdout?.on("data", (data: Buffer) => { + stdoutBuffer += data.toString("utf8"); + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() ?? ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; @@ -456,13 +474,13 @@ export default async function globalSetup() { const clean = stripAnsi(trimmed); try { const obj = JSON.parse(clean) as { msg?: string; url?: string }; - if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') { + if (obj.msg === "pairing_offer" && typeof obj.url === "string") { offerPayload = decodeOfferFromFragmentUrl(obj.url); offerResolve?.(); } } catch { const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/); - if (match && clean.includes('pairing_offer')) { + if (match && clean.includes("pairing_offer")) { try { offerPayload = decodeOfferFromFragmentUrl(match[0]); offerResolve?.(); @@ -476,8 +494,11 @@ export default async function globalSetup() { } }); - daemonProcess.stderr?.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter((line) => line.trim()); + daemonProcess.stderr?.on("data", (data: Buffer) => { + const lines = data + .toString() + .split("\n") + .filter((line) => line.trim()); for (const line of lines) { daemonLineBuffer.add(`[stderr] ${line}`); console.error(`[daemon] ${line}`); @@ -487,12 +508,12 @@ export default async function globalSetup() { // Wait for both daemon and Metro to be ready await Promise.all([ waitForServer(port, { - label: 'Paseo daemon', + label: "Paseo daemon", childProcess: daemonProcess, getRecentOutput: daemonLineBuffer.dump, }), waitForServer(metroPort, { - label: 'Metro web server', + label: "Metro web server", timeoutMs: 120000, // Metro can take longer to start childProcess: metroProcess, getRecentOutput: metroLineBuffer.dump, @@ -503,11 +524,11 @@ export default async function globalSetup() { await Promise.race([ offerPromise, new Promise((_, reject) => - setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000) + setTimeout(() => reject(new Error("Timed out waiting for pairing_offer log")), 15000), ), ]); if (!offerPayload) { - throw new Error('pairing_offer was not parsed from daemon logs'); + throw new Error("pairing_offer was not parsed from daemon logs"); } const offer = offerPayload as OfferPayload; @@ -516,11 +537,13 @@ export default async function globalSetup() { process.env.E2E_SERVER_ID = offer.serverId; process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64; process.env.E2E_METRO_PORT = String(metroPort); - console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`); + console.log( + `[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`, + ); return async () => { await cleanup(); - console.log('[e2e] Test daemon stopped'); + console.log("[e2e] Test daemon stopped"); }; } catch (error) { await cleanup(); diff --git a/packages/app/e2e/helpers/agent-bottom-anchor.ts b/packages/app/e2e/helpers/agent-bottom-anchor.ts index e70977779..7ecf0ac9b 100644 --- a/packages/app/e2e/helpers/agent-bottom-anchor.ts +++ b/packages/app/e2e/helpers/agent-bottom-anchor.ts @@ -34,10 +34,7 @@ export type DaemonClientInstance = { initialPrompt: string; }): Promise<{ id: string }>; sendAgentMessage(agentId: string, text: string): Promise; - waitForFinish( - agentId: string, - timeout?: number - ): Promise<{ status: string }>; + waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>; }; function getDaemonWsUrl(): string { @@ -86,14 +83,16 @@ export function createReplyTurn(label: string): { }; } -async function loadDaemonClientConstructor(): Promise DaemonClientInstance> { +async function loadDaemonClientConstructor(): Promise< + new (config: { + url: string; + clientId: string; + clientType: "cli"; + }) => DaemonClientInstance +> { const repoRoot = path.resolve(process.cwd(), "../.."); const moduleUrl = pathToFileURL( - path.join(repoRoot, "packages/server/dist/server/server/exports.js") + path.join(repoRoot, "packages/server/dist/server/server/exports.js"), ).href; const mod = (await import(moduleUrl)) as { DaemonClient: new (config: { @@ -138,7 +137,7 @@ export async function seedBottomAnchorAgent(input: { const initialFinish = await input.client.waitForFinish(created.id, 120000); if (initialFinish.status !== "idle") { throw new Error( - `Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.` + `Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`, ); } @@ -150,7 +149,7 @@ export async function seedBottomAnchorAgent(input: { const finish = await input.client.waitForFinish(created.id, 120000); if (finish.status !== "idle") { throw new Error( - `Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.` + `Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`, ); } } @@ -183,18 +182,13 @@ export async function readScrollMetrics(page: Page): Promise { const scrollElement = candidates.sort( (left, right) => - right.scrollHeight - - right.clientHeight - - (left.scrollHeight - left.clientHeight) + right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight), )[0] ?? (root as HTMLElement); const offsetY = Math.max(0, scrollElement.scrollTop); const contentHeight = Math.max(0, scrollElement.scrollHeight); const viewportHeight = Math.max(0, scrollElement.clientHeight); - const distanceFromBottom = Math.max( - 0, - contentHeight - (offsetY + viewportHeight) - ); + const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight)); return { offsetY, @@ -218,7 +212,7 @@ export async function scrollUpFromBottom(page: Page, pixels: number): Promise { export async function waitForContentGrowth( page: Page, - previousContentHeight: number + previousContentHeight: number, ): Promise { await expect .poll(async () => { @@ -280,8 +274,8 @@ export async function waitForContentGrowth( export async function getChatContainerKey(page: Page): Promise { return getVisibleChatScroll(page).evaluate((element) => { - const nativeId = (element as HTMLElement).id; - const prefix = "agent-chat-scroll-"; - return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null; - }); + const nativeId = (element as HTMLElement).id; + const prefix = "agent-chat-scroll-"; + return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null; + }); } diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index 1b212300e..e660be007 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -1,20 +1,19 @@ -import { expect, type Page } from '@playwright/test'; -import { - buildCreateAgentPreferences, - buildSeededHost, -} from './daemon-registry'; +import { expect, type Page } from "@playwright/test"; +import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry"; function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function getE2EDaemonPort(): string { const port = process.env.E2E_DAEMON_PORT; if (!port) { - throw new Error('E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).'); + throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup)."); } - if (port === '6767') { - throw new Error('E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.'); + if (port === "6767") { + throw new Error( + "E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.", + ); } return port; } @@ -24,25 +23,38 @@ async function ensureE2EStorageSeeded(page: Page): Promise { const expectedEndpoint = `127.0.0.1:${port}`; const expectedServerId = process.env.E2E_SERVER_ID; if (!expectedServerId) { - throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).'); + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); } - const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => { - const raw = localStorage.getItem('@paseo:daemon-registry'); - if (!raw) return true; - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed) || parsed.length !== 1) return true; - const entry = parsed[0] as any; - if (entry?.serverId !== expectedServerId) return true; - const connections = entry?.connections; - if (!Array.isArray(connections)) return true; - if (connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true; - return !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint); - } catch { - return true; - } - }, { expectedEndpoint, expectedServerId }); + const needsReset = await page.evaluate( + ({ expectedEndpoint, expectedServerId }) => { + const raw = localStorage.getItem("@paseo:daemon-registry"); + if (!raw) return true; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length !== 1) return true; + const entry = parsed[0] as any; + if (entry?.serverId !== expectedServerId) return true; + const connections = entry?.connections; + if (!Array.isArray(connections)) return true; + if ( + connections.some( + (c: any) => + c?.type === "directTcp" && + typeof c?.endpoint === "string" && + /:6767\b/.test(c.endpoint), + ) + ) + return true; + return !connections.some( + (c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint, + ); + } catch { + return true; + } + }, + { expectedEndpoint, expectedServerId }, + ); if (!needsReset) { return; @@ -57,12 +69,12 @@ async function ensureE2EStorageSeeded(page: Page): Promise { const preferences = buildCreateAgentPreferences(expectedServerId); await page.evaluate( ({ daemon, preferences }) => { - localStorage.setItem('@paseo:e2e', '1'); - localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); - localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); - localStorage.removeItem('@paseo:settings'); + localStorage.setItem("@paseo:e2e", "1"); + localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon])); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); + localStorage.removeItem("@paseo:settings"); }, - { daemon, preferences } + { daemon, preferences }, ); await page.reload(); @@ -73,81 +85,98 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise { const expectedEndpoint = `127.0.0.1:${port}`; const expectedServerId = process.env.E2E_SERVER_ID; if (!expectedServerId) { - throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).'); + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); } const snapshot = await page.evaluate(() => { - const registryRaw = localStorage.getItem('@paseo:daemon-registry'); - const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); + const registryRaw = localStorage.getItem("@paseo:daemon-registry"); + const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences"); return { registryRaw, prefsRaw }; }); if (!snapshot.registryRaw) { - throw new Error('E2E expected @paseo:daemon-registry to be set before app load.'); + throw new Error("E2E expected @paseo:daemon-registry to be set before app load."); } let registry: any; try { registry = JSON.parse(snapshot.registryRaw); } catch { - throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.'); + throw new Error("E2E expected @paseo:daemon-registry to be valid JSON."); } if (!Array.isArray(registry) || registry.length !== 1) { throw new Error( - `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).` + `E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`, ); } const daemon = registry[0]; - if (typeof daemon?.serverId !== 'string' || daemon.serverId.length === 0) { - throw new Error(`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`); + if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) { + throw new Error( + `E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`, + ); } if (daemon.serverId !== expectedServerId) { - throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`); + throw new Error( + `E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`, + ); } const connections: unknown = daemon?.connections; if ( !Array.isArray(connections) || - !connections.some((c: any) => c?.type === 'directTcp' && c?.endpoint === expectedEndpoint) + !connections.some((c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint) ) { throw new Error( - `E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).` + `E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`, ); } - if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'directTcp' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) { - throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`); + if ( + Array.isArray(connections) && + connections.some( + (c: any) => + c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint), + ) + ) { + throw new Error( + `E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`, + ); } if (!snapshot.prefsRaw) { - throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.'); + throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load."); } try { const prefs = JSON.parse(snapshot.prefsRaw) as any; if (prefs?.serverId !== daemon.serverId) { throw new Error( - `E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).` + `E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.serverId}) (got ${String(prefs?.serverId)}).`, ); } } catch (error) { if (error instanceof Error) throw error; - throw new Error('E2E expected @paseo:create-agent-preferences to be valid JSON.'); + throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON."); } } export const gotoAppShell = async (page: Page) => { - await page.goto('/'); + await page.goto("/"); await ensureE2EStorageSeeded(page); }; export const gotoHome = async (page: Page) => { await gotoAppShell(page); - const composer = page.getByRole('textbox', { name: 'Message agent...' }); - if (!(await composer.first().isVisible().catch(() => false))) { - const addProjectCta = page.getByText('Add a project', { exact: true }).first(); - const addProjectSidebar = page.getByText('Add project', { exact: true }).first(); - const newAgentButton = page.getByText('New agent', { exact: true }).first(); + const composer = page.getByRole("textbox", { name: "Message agent..." }); + if ( + !(await composer + .first() + .isVisible() + .catch(() => false)) + ) { + const addProjectCta = page.getByText("Add a project", { exact: true }).first(); + const addProjectSidebar = page.getByText("Add project", { exact: true }).first(); + const newAgentButton = page.getByText("New agent", { exact: true }).first(); await expect .poll( @@ -155,7 +184,7 @@ export const gotoHome = async (page: Page) => { (await addProjectCta.isVisible().catch(() => false)) || (await addProjectSidebar.isVisible().catch(() => false)) || (await newAgentButton.isVisible().catch(() => false)), - { timeout: 10000 } + { timeout: 10000 }, ) .toBe(true); @@ -173,7 +202,7 @@ export const gotoHome = async (page: Page) => { export const openSettings = async (page: Page) => { const serverId = process.env.E2E_SERVER_ID; if (!serverId) { - throw new Error('E2E_SERVER_ID is not set (expected from Playwright globalSetup).'); + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); } // Navigate through the real app control so route changes stay aligned with UI behavior. @@ -189,23 +218,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { .first(); await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 }); - const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first(); - const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first(); - const worktreePicker = page.getByTestId('worktree-attach-picker'); - const worktreeSheetTitle = page.getByText('Select worktree', { exact: true }).first(); + const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first(); + const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first(); + const worktreePicker = page.getByTestId("worktree-attach-picker"); + const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first(); const closeBottomSheet = async () => { - const bottomSheetBackdrop = page - .getByRole('button', { name: 'Bottom sheet backdrop' }) - .first(); - const bottomSheetHandle = page - .getByRole('slider', { name: 'Bottom sheet handle' }) - .first(); + const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first(); + const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first(); for (let attempt = 0; attempt < 3; attempt += 1) { if (!(await bottomSheetBackdrop.isVisible())) { return; } await bottomSheetBackdrop.click({ force: true }); - await page.keyboard.press('Escape').catch(() => undefined); + await page.keyboard.press("Escape").catch(() => undefined); await page.waitForTimeout(200); } if (await bottomSheetBackdrop.isVisible()) { @@ -225,7 +250,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) { return; } - const attachToggle = page.getByTestId('worktree-attach-toggle'); + const attachToggle = page.getByTestId("worktree-attach-toggle"); if (await attachToggle.isVisible()) { await attachToggle.click({ force: true }); await page.waitForTimeout(200); @@ -246,26 +271,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { await closeBottomSheet(); await workingDirectorySelect.click({ force: true }); } - await expect - .poll(async () => pickerInputVisible(), { timeout: 10000 }) - .toBe(true); + await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true); } - const trimmedDirectory = directory.replace(/\/+$/, ''); - const activeInput = - (await directorySearchInput.isVisible().catch(() => false)) - ? directorySearchInput - : legacyInput; + const trimmedDirectory = directory.replace(/\/+$/, ""); + const activeInput = (await directorySearchInput.isVisible().catch(() => false)) + ? directorySearchInput + : legacyInput; await activeInput.fill(trimmedDirectory); if (activeInput === directorySearchInput) { // Combobox custom rows can be either plain path labels or prefixed labels. const plainOption = page - .getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i')) + .getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i")) .first(); const prefixedUseOption = page - .getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i')) + .getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i")) .first(); if (await plainOption.isVisible().catch(() => false)) { @@ -274,33 +296,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => { await prefixedUseOption.click({ force: true }); } else { // Fallback: accept highlighted option (directory suggestion). - await activeInput.press('Enter'); + await activeInput.press("Enter"); } } else { // Legacy path picker fallback. - await activeInput.press('Enter'); + await activeInput.press("Enter"); } // Wait for picker to close. await expect(activeInput).not.toBeVisible({ timeout: 10000 }); const directoryCandidates = new Set([trimmedDirectory]); - if (trimmedDirectory.startsWith('/var/')) { + if (trimmedDirectory.startsWith("/var/")) { directoryCandidates.add(`/private${trimmedDirectory}`); } - if (trimmedDirectory.startsWith('/private/var/')) { - directoryCandidates.add(trimmedDirectory.replace(/^\/private/, '')); + if (trimmedDirectory.startsWith("/private/var/")) { + directoryCandidates.add(trimmedDirectory.replace(/^\/private/, "")); } - const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory; + const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory; - await expect.poll(async () => { - const text = await workingDirectorySelect.innerText().catch(() => ''); - if (text.includes(basename)) return true; - for (const candidate of directoryCandidates) { - if (text.includes(candidate)) return true; - } - return false; - }, { timeout: 30000 }).toBe(true); + await expect + .poll( + async () => { + const text = await workingDirectorySelect.innerText().catch(() => ""); + if (text.includes(basename)) return true; + for (const candidate of directoryCandidates) { + if (text.includes(candidate)) return true; + } + return false; + }, + { timeout: 30000 }, + ) + .toBe(true); }; export const ensureHostSelected = async (page: Page) => { @@ -318,19 +345,21 @@ export const ensureHostSelected = async (page: Page) => { } const fix = await page.evaluate(() => { - const registryRaw = localStorage.getItem('@paseo:daemon-registry'); - const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences'); - if (!registryRaw || !prefsRaw) return { ok: false, reason: 'missing storage' } as const; + const registryRaw = localStorage.getItem("@paseo:daemon-registry"); + const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences"); + if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const; const registry = JSON.parse(registryRaw) as any[]; const prefs = JSON.parse(prefsRaw) as any; - if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const; + if (!Array.isArray(registry) || registry.length !== 1) + return { ok: false, reason: "registry shape" } as const; const serverId = registry[0]?.serverId; - if (typeof serverId !== 'string' || serverId.length === 0) return { ok: false, reason: 'missing serverId' } as const; + if (typeof serverId !== "string" || serverId.length === 0) + return { ok: false, reason: "missing serverId" } as const; prefs.serverId = serverId; - localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs)); + localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs)); // Prevent the fixture's init-script from overwriting the corrected prefs on reload. - const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; - localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); + const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1"; + localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce); return { ok: true } as const; }); @@ -342,20 +371,22 @@ export const ensureHostSelected = async (page: Page) => { await assertE2EUsesSeededTestDaemon(page); } - const input = page.getByRole('textbox', { name: 'Message agent...' }); + const input = page.getByRole("textbox", { name: "Message agent..." }); await expect(input).toBeVisible(); if (await input.isEditable()) { return; } - const selectHostLabel = page.getByText('Select host', { exact: true }); + const selectHostLabel = page.getByText("Select host", { exact: true }); if (await selectHostLabel.isVisible()) { await selectHostLabel.click(); // E2E safety: we enforce a single seeded daemon, so the option should be unambiguous. - const localhostOption = page.getByText('localhost', { exact: true }).first(); - const daemonIdOption = page.getByText(process.env.E2E_SERVER_ID ?? 'srv_e2e_test_daemon', { exact: true }).first(); + const localhostOption = page.getByText("localhost", { exact: true }).first(); + const daemonIdOption = page + .getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true }) + .first(); if (await localhostOption.isVisible()) { await localhostOption.click(); @@ -369,11 +400,11 @@ export const ensureHostSelected = async (page: Page) => { }; export const createAgent = async (page: Page, message: string) => { - const input = page.getByRole('textbox', { name: 'Message agent...' }); + const input = page.getByRole("textbox", { name: "Message agent..." }); await expect(input).toBeEditable(); await preferFastThinkingOption(page); await input.fill(message); - await input.press('Enter'); + await input.press("Enter"); // The composer may remain on the draft screen briefly while the initial run starts, // so assert the user-visible result instead of forcing one route shape here. @@ -385,21 +416,23 @@ export const createAgent = async (page: Page, message: string) => { async function preferFastThinkingOption(page: Page): Promise { const providerTrigger = page - .locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible') + .locator( + '[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible', + ) .first(); if (await providerTrigger.isVisible().catch(() => false)) { - const providerText = ((await providerTrigger.innerText().catch(() => '')) ?? '').trim(); + const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim(); if (!/codex/i.test(providerText)) { return; } } - const thinkingTrigger = page.getByTestId('agent-thinking-selector').first(); + const thinkingTrigger = page.getByTestId("agent-thinking-selector").first(); if (!(await thinkingTrigger.isVisible().catch(() => false))) { return; } - const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => '')) ?? '') + const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "") .trim() .toLowerCase(); if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) { @@ -407,16 +440,16 @@ async function preferFastThinkingOption(page: Page): Promise { } await thinkingTrigger.click(); - const menu = page.getByTestId('agent-thinking-menu').first(); + const menu = page.getByTestId("agent-thinking-menu").first(); if (!(await menu.isVisible().catch(() => false))) { return; } - const preferredLabels = ['low', 'minimal', 'off', 'medium']; + const preferredLabels = ["low", "minimal", "off", "medium"]; let selected = false; for (const label of preferredLabels) { const option = menu - .getByRole('button', { name: new RegExp(`^${escapeRegex(label)}$`, 'i') }) + .getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") }) .first(); if (await option.isVisible().catch(() => false)) { await option.click({ force: true }); @@ -426,11 +459,11 @@ async function preferFastThinkingOption(page: Page): Promise { } if (!selected) { - const options = menu.getByRole('button'); + const options = menu.getByRole("button"); const count = await options.count(); for (let index = 0; index < count; index += 1) { const option = options.nth(index); - const label = ((await option.innerText().catch(() => '')) ?? '').trim(); + const label = ((await option.innerText().catch(() => "")) ?? "").trim(); if (!label) { continue; } @@ -444,7 +477,7 @@ async function preferFastThinkingOption(page: Page): Promise { } if (!selected) { - await page.keyboard.press('Escape').catch(() => undefined); + await page.keyboard.press("Escape").catch(() => undefined); return; } @@ -462,15 +495,17 @@ export interface AgentConfig { export const selectProvider = async (page: Page, provider: string) => { const normalizedProvider = provider.trim(); if (!normalizedProvider) { - throw new Error('Provider must be a non-empty string.'); + throw new Error("Provider must be a non-empty string."); } const providerTrigger = page - .locator('[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible') + .locator( + '[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible', + ) .first(); if ( await providerTrigger - .getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i')) + .getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")) .first() .isVisible() .catch(() => false) @@ -481,20 +516,18 @@ export const selectProvider = async (page: Page, provider: string) => { if (await providerTrigger.isVisible().catch(() => false)) { await providerTrigger.click(); } else { - const providerLabel = page.getByText('PROVIDER', { exact: true }).first(); + const providerLabel = page.getByText("PROVIDER", { exact: true }).first(); await expect(providerLabel).toBeVisible(); await providerLabel.click(); } - const dialog = page.getByRole('dialog').last(); - const searchInput = dialog.getByRole('textbox', { name: /search provider/i }).first(); + const dialog = page.getByRole("dialog").last(); + const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first(); if (await searchInput.isVisible().catch(() => false)) { await searchInput.fill(normalizedProvider); } - const option = dialog - .getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, 'i')) - .first(); + const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first(); await expect(option).toBeVisible(); await option.click(); }; @@ -502,15 +535,17 @@ export const selectProvider = async (page: Page, provider: string) => { export const selectModel = async (page: Page, model: string) => { const normalizedModel = model.trim(); if (!normalizedModel) { - throw new Error('Model must be a non-empty string.'); + throw new Error("Model must be a non-empty string."); } const modelTrigger = page - .locator('[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible') + .locator( + '[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible', + ) .first(); if ( await modelTrigger - .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i')) + .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i")) .first() .isVisible() .catch(() => false) @@ -521,21 +556,21 @@ export const selectModel = async (page: Page, model: string) => { if (await modelTrigger.isVisible().catch(() => false)) { await modelTrigger.click(); } else { - const modelLabel = page.getByText('MODEL', { exact: true }).first(); + const modelLabel = page.getByText("MODEL", { exact: true }).first(); await expect(modelLabel).toBeVisible(); await modelLabel.click(); } // Wait for the model dropdown to open - const searchInput = page.getByRole('textbox', { name: /search model/i }); + const searchInput = page.getByRole("textbox", { name: /search model/i }); await expect(searchInput).toBeVisible({ timeout: 10000 }); // Type to search/filter models await searchInput.fill(normalizedModel); - const dialog = page.getByRole('dialog'); + const dialog = page.getByRole("dialog"); const exactOption = dialog - .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, 'i')) + .getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i")) .first(); const exactVisible = await exactOption.isVisible().catch(() => false); if (exactVisible) { @@ -543,39 +578,39 @@ export const selectModel = async (page: Page, model: string) => { } else { // Modern labels include version suffixes (for example "Haiku 4.5"), so // select the first filtered result using keyboard confirm. - await searchInput.press('Enter'); + await searchInput.press("Enter"); } // Wait for dropdown to close if (await searchInput.isVisible().catch(() => false)) { - await page.keyboard.press('Escape').catch(() => undefined); + await page.keyboard.press("Escape").catch(() => undefined); } await expect(searchInput).not.toBeVisible({ timeout: 5000 }); }; export const selectMode = async (page: Page, mode: string) => { const modeTrigger = page - .locator('[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible') + .locator( + '[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible', + ) .first(); if (await modeTrigger.isVisible().catch(() => false)) { await modeTrigger.click(); } else { - const modeLabel = page.getByText('MODE', { exact: true }).first(); + const modeLabel = page.getByText("MODE", { exact: true }).first(); await expect(modeLabel).toBeVisible(); await modeLabel.click(); } // Wait for the mode dropdown to open - const searchInput = page.getByRole('textbox', { name: /search mode/i }); + const searchInput = page.getByRole("textbox", { name: /search mode/i }); await expect(searchInput).toBeVisible({ timeout: 10000 }); // Type to filter modes await searchInput.fill(mode); - const dialog = page.getByRole('dialog'); - const option = dialog - .getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i')) - .first(); + const dialog = page.getByRole("dialog"); + const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first(); await expect(option).toBeVisible(); await option.click({ force: true }); @@ -605,7 +640,7 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) => export const createAgentInRepo = async ( page: Page, - config: Pick + config: Pick, ) => { await gotoHome(page); await ensureHostSelected(page); @@ -614,25 +649,25 @@ export const createAgentInRepo = async ( }; export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => { - const promptText = page.getByTestId('permission-request-question').first(); + const promptText = page.getByTestId("permission-request-question").first(); await expect(promptText).toBeVisible({ timeout }); }; export const allowPermission = async (page: Page) => { - const acceptButton = page.getByTestId('permission-request-accept').first(); + const acceptButton = page.getByTestId("permission-request-accept").first(); await expect(acceptButton).toBeVisible({ timeout: 5000 }); await acceptButton.click(); }; export const denyPermission = async (page: Page) => { - const denyButton = page.getByTestId('permission-request-deny').first(); + const denyButton = page.getByTestId("permission-request-deny").first(); await expect(denyButton).toBeVisible({ timeout: 5000 }); await denyButton.click(); }; export async function waitForAgentFinishUI(page: Page, timeout = 30000) { // Wait for the stop button to disappear - const stopButton = page.getByRole('button', { name: /stop|cancel/i }); + const stopButton = page.getByRole("button", { name: /stop|cancel/i }); // First, let's debug what's happening - wait a bit to see the state await page.waitForTimeout(2000); @@ -649,9 +684,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) { const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i); // Wait for the tool call result to appear - await expect(toolCallResult).toBeVisible({ timeout: 10000 }).catch(() => { - // If no specific message, just wait for the button to disappear - }); + await expect(toolCallResult) + .toBeVisible({ timeout: 10000 }) + .catch(() => { + // If no specific message, just wait for the button to disappear + }); // Now wait for the stop button to disappear await expect(stopButton).not.toBeVisible({ timeout }); diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts index 37718f12b..9415a8d91 100644 --- a/packages/app/e2e/helpers/workspace-tabs.ts +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -38,7 +38,7 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise export async function sampleWorkspaceTabIds( page: Page, - options: { durationMs?: number; intervalMs?: number } = {} + options: { durationMs?: number; intervalMs?: number } = {}, ): Promise { const durationMs = options.durationMs ?? 2_500; const intervalMs = options.intervalMs ?? 50; diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index e11b50147..af64fa0f5 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -1,38 +1,38 @@ -import { expect, type Page } from '@playwright/test'; -import { buildHostWorkspaceRoute } from '@/utils/host-routes'; -import { gotoHome } from './app'; +import { expect, type Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { gotoHome } from "./app"; export async function openNewAgentComposer(page: Page): Promise { await gotoHome(page); } export function workspaceLabelFromPath(value: string): string { - const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); - const parts = normalized.split('/').filter(Boolean); + const normalized = value.replace(/\\/g, "/").replace(/\/+$/, ""); + const parts = normalized.split("/").filter(Boolean); return parts[parts.length - 1] ?? normalized; } function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function candidateWorkspaceIds(inputPath: string): string[] { - const trimmed = inputPath.replace(/\/+$/, ''); + const trimmed = inputPath.replace(/\/+$/, ""); const candidates = new Set([trimmed]); - if (trimmed.startsWith('/var/')) { + if (trimmed.startsWith("/var/")) { candidates.add(`/private${trimmed}`); } - if (trimmed.startsWith('/private/var/')) { - candidates.add(trimmed.replace(/^\/private/, '')); + if (trimmed.startsWith("/private/var/")) { + candidates.add(trimmed.replace(/^\/private/, "")); } return Array.from(candidates); } function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) { const ids = candidateWorkspaceIds(workspacePath).map( - (id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]` + (id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`, ); - return page.locator(ids.join(',')).first(); + return page.locator(ids.join(",")).first(); } export async function switchWorkspaceViaSidebar(input: { @@ -52,10 +52,10 @@ export async function switchWorkspaceViaSidebar(input: { export async function expectWorkspaceHeader( page: Page, - input: { title: string; subtitle: string } + input: { title: string; subtitle: string }, ): Promise { - const titleLocator = page.getByTestId('workspace-header-title'); - const subtitleLocator = page.getByTestId('workspace-header-subtitle'); + const titleLocator = page.getByTestId("workspace-header-title"); + const subtitleLocator = page.getByTestId("workspace-header-subtitle"); await expect(titleLocator.first()).toHaveText(input.title, { timeout: 30_000, @@ -66,9 +66,9 @@ export async function expectWorkspaceHeader( } export async function seedWorkspaceActivity(page: Page, marker: string): Promise { - const input = page.getByRole('textbox', { name: 'Message agent...' }); + const input = page.getByRole("textbox", { name: "Message agent..." }); await expect(input).toBeEditable({ timeout: 30_000 }); await input.fill(marker); - await input.press('Enter'); + await input.press("Enter"); await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); } diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index 37d6f0813..d49d67a87 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -1,7 +1,7 @@ -import { execSync } from 'node:child_process'; -import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; +import { execSync } from "node:child_process"; +import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; type TempRepo = { path: string; @@ -9,29 +9,29 @@ type TempRepo = { }; export const createTempGitRepo = async ( - prefix = 'paseo-e2e-', - options?: { withRemote?: boolean } + prefix = "paseo-e2e-", + options?: { withRemote?: boolean }, ): Promise => { // Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping. - const tempRoot = process.platform === 'win32' ? tmpdir() : '/tmp'; + const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; const repoPath = await mkdtemp(path.join(tempRoot, prefix)); const withRemote = options?.withRemote ?? false; - execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' }); - execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' }); - execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' }); - execSync('git config commit.gpgsign false', { cwd: repoPath, stdio: 'ignore' }); - await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n'); - execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' }); - execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' }); + execSync("git init -b main", { cwd: repoPath, stdio: "ignore" }); + execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" }); + execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" }); + execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" }); + await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n"); + execSync("git add README.md", { cwd: repoPath, stdio: "ignore" }); + execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" }); if (withRemote) { // Deterministic local remote to avoid relying on external auth/network in e2e. - const remoteDir = path.join(repoPath, 'remote.git'); + const remoteDir = path.join(repoPath, "remote.git"); await mkdir(remoteDir, { recursive: true }); - execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' }); - execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: 'ignore' }); - execSync('git push -u origin main', { cwd: repoPath, stdio: 'ignore' }); + execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); + execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" }); + execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" }); } return { diff --git a/packages/app/eslint.config.js b/packages/app/eslint.config.js index 5025da683..ece97001a 100644 --- a/packages/app/eslint.config.js +++ b/packages/app/eslint.config.js @@ -1,10 +1,10 @@ // https://docs.expo.dev/guides/using-eslint/ -const { defineConfig } = require('eslint/config'); -const expoConfig = require('eslint-config-expo/flat'); +const { defineConfig } = require("eslint/config"); +const expoConfig = require("eslint-config-expo/flat"); module.exports = defineConfig([ expoConfig, { - ignores: ['dist/*'], + ignores: ["dist/*"], }, ]); diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 84280ac43..b03c7197e 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -1,12 +1,13 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; // E2E_METRO_PORT is set dynamically by global-setup.ts after finding a free port // This allows multiple test runs in parallel across different worktrees -const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}`; +const baseURL = + process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; export default defineConfig({ - testDir: './e2e', - globalSetup: './e2e/global-setup.ts', + testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", timeout: 60_000, expect: { timeout: 10_000, @@ -16,17 +17,17 @@ export default defineConfig({ fullyParallel: false, workers: 1, retries: process.env.CI ? 1 : 0, - reporter: [['list']], + reporter: [["list"]], use: { baseURL, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", }, projects: [ { - name: 'Desktop Chrome', - use: { ...devices['Desktop Chrome'] }, + name: "Desktop Chrome", + use: { ...devices["Desktop Chrome"] }, }, ], // Note: Metro is started by global-setup.ts on a dynamic port to allow parallel test runs diff --git a/packages/app/playwright.firefox.config.ts b/packages/app/playwright.firefox.config.ts index cf66db355..dee37bcbe 100644 --- a/packages/app/playwright.firefox.config.ts +++ b/packages/app/playwright.firefox.config.ts @@ -1,8 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; const baseURL = - process.env.E2E_BASE_URL ?? - `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; + process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; export default defineConfig({ testDir: "./e2e", diff --git a/packages/app/playwright.webkit.tmp.config.ts b/packages/app/playwright.webkit.tmp.config.ts index 9cdc91600..346d2c109 100644 --- a/packages/app/playwright.webkit.tmp.config.ts +++ b/packages/app/playwright.webkit.tmp.config.ts @@ -1,11 +1,11 @@ -import { defineConfig, devices } from '@playwright/test' +import { defineConfig, devices } from "@playwright/test"; const baseURL = - process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? '8081'}` + process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; export default defineConfig({ - testDir: './e2e', - globalSetup: './e2e/global-setup.ts', + testDir: "./e2e", + globalSetup: "./e2e/global-setup.ts", timeout: 60_000, expect: { timeout: 10_000, @@ -13,17 +13,17 @@ export default defineConfig({ fullyParallel: false, workers: 1, retries: 0, - reporter: [['list']], + reporter: [["list"]], use: { baseURL, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", }, projects: [ { - name: 'Desktop Safari', - use: { ...devices['Desktop Safari'] }, + name: "Desktop Safari", + use: { ...devices["Desktop Safari"] }, }, ], -}) +}); diff --git a/packages/app/scripts/reset-project.js b/packages/app/scripts/reset-project.js index 51dff15ac..70724e60f 100755 --- a/packages/app/scripts/reset-project.js +++ b/packages/app/scripts/reset-project.js @@ -91,7 +91,7 @@ const moveDirectories = async (userInput) => { userInput === "y" ? `\n3. Delete the /${exampleDir} directory when you're done referencing it.` : "" - }` + }`, ); } catch (error) { console.error(`❌ Error during script execution: ${error.message}`); @@ -108,5 +108,5 @@ rl.question( console.log("❌ Invalid input. Please enter 'Y' or 'N'."); rl.close(); } - } + }, ); diff --git a/packages/app/src/app-support/index-startup.ts b/packages/app/src/app-support/index-startup.ts index 627593fc7..b0ffffe41 100644 --- a/packages/app/src/app-support/index-startup.ts +++ b/packages/app/src/app-support/index-startup.ts @@ -1,36 +1,36 @@ -export const WELCOME_ROUTE = '/welcome' +export const WELCOME_ROUTE = "/welcome"; export function shouldWaitOnStartupRace(input: { - onlineServerId: string | null - hasTimedOut: boolean - isDesktopStartupRace: boolean - daemonCount: number - pathname: string + onlineServerId: string | null; + hasTimedOut: boolean; + isDesktopStartupRace: boolean; + daemonCount: number; + pathname: string; }): boolean { if (input.onlineServerId) { - return false + return false; } if (input.pathname === WELCOME_ROUTE) { - return false + return false; } if (input.hasTimedOut) { - return false + return false; } - return input.isDesktopStartupRace || input.daemonCount > 0 + return input.isDesktopStartupRace || input.daemonCount > 0; } export function shouldRedirectToWelcome(input: { - onlineServerId: string | null - hasTimedOut: boolean - pathname: string - isDesktopStartupRace: boolean - daemonCount: number + onlineServerId: string | null; + hasTimedOut: boolean; + pathname: string; + isDesktopStartupRace: boolean; + daemonCount: number; }): boolean { if (input.onlineServerId || !input.hasTimedOut) { - return false + return false; } - if (input.pathname !== '/' && input.pathname !== '') { - return false + if (input.pathname !== "/" && input.pathname !== "") { + return false; } - return input.isDesktopStartupRace || input.daemonCount > 0 + return input.isDesktopStartupRace || input.daemonCount > 0; } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 91ccb4026..6ceedf4b7 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -2,9 +2,7 @@ import { useEffect, useRef } from "react"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { - buildHostRootRoute, -} from "@/utils/host-routes"; +import { buildHostRootRoute } from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; export default function HostAgentReadyRoute() { @@ -43,7 +41,7 @@ export default function HostAgentReadyRoute() { serverId, workspaceId: normalizedCwd, target: { kind: "agent", agentId }, - }) as any + }) as any, ); } }, [agentCwd, agentId, router, serverId]); @@ -87,7 +85,7 @@ export default function HostAgentReadyRoute() { serverId, workspaceId: cwd, target: { kind: "agent", agentId }, - }) as any + }) as any, ); return; } diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 57b46f9c5..0d275ecd1 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -17,11 +17,11 @@ export default function HostIndexRoute() { const params = useLocalSearchParams<{ serverId?: string }>(); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const { isLoading: preferencesLoading } = useFormPreferences(); - const sessionAgents = useSessionStore( - (state) => (serverId ? state.sessions[serverId]?.agents : undefined) + const sessionAgents = useSessionStore((state) => + serverId ? state.sessions[serverId]?.agents : undefined, ); - const sessionWorkspaces = useSessionStore( - (state) => (serverId ? state.sessions[serverId]?.workspaces : undefined) + const sessionWorkspaces = useSessionStore((state) => + serverId ? state.sessions[serverId]?.workspaces : undefined, ); useEffect(() => { @@ -44,12 +44,10 @@ export default function HostIndexRoute() { ? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt) : []; visibleAgents.sort( - (left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime() + (left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(), ); - const visibleWorkspaces = sessionWorkspaces - ? Array.from(sessionWorkspaces.values()) - : []; + const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : []; visibleWorkspaces.sort((left, right) => { const leftTime = left.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; const rightTime = right.activityAt?.getTime() ?? Number.NEGATIVE_INFINITY; @@ -63,7 +61,7 @@ export default function HostIndexRoute() { serverId, workspaceId: primaryAgent.cwd.trim(), target: { kind: "agent", agentId: primaryAgent.id }, - }) as any + }) as any, ); return; } @@ -78,14 +76,7 @@ export default function HostIndexRoute() { }, HOST_ROOT_REDIRECT_DELAY_MS); return () => clearTimeout(timer); - }, [ - pathname, - preferencesLoading, - router, - serverId, - sessionAgents, - sessionWorkspaces, - ]); + }, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]); return null; } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index 6c81f9374..2afc57223 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -1,80 +1,82 @@ -import { useEffect, useRef } from 'react' -import { useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router' -import type { WorkspaceTabTarget } from '@/stores/workspace-tabs-store' -import { WorkspaceScreen } from '@/screens/workspace/workspace-screen' +import { useEffect, useRef } from "react"; +import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +import { WorkspaceScreen } from "@/screens/workspace/workspace-screen"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment, parseWorkspaceOpenIntent, type WorkspaceOpenIntent, -} from '@/utils/host-routes' -import { prepareWorkspaceTab } from '@/utils/workspace-navigation' +} from "@/utils/host-routes"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; function getParamValue(value: string | string[] | undefined): string { - if (typeof value === 'string') { - return value.trim() + if (typeof value === "string") { + return value.trim(); } if (Array.isArray(value)) { - const firstValue = value[0] - return typeof firstValue === 'string' ? firstValue.trim() : '' + const firstValue = value[0]; + return typeof firstValue === "string" ? firstValue.trim() : ""; } - return '' + return ""; } function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarget { - if (openIntent.kind === 'agent') { - return { kind: 'agent', agentId: openIntent.agentId } + if (openIntent.kind === "agent") { + return { kind: "agent", agentId: openIntent.agentId }; } - if (openIntent.kind === 'terminal') { - return { kind: 'terminal', terminalId: openIntent.terminalId } + if (openIntent.kind === "terminal") { + return { kind: "terminal", terminalId: openIntent.terminalId }; } - if (openIntent.kind === 'file') { - return { kind: 'file', path: openIntent.path } + if (openIntent.kind === "file") { + return { kind: "file", path: openIntent.path }; } - return { kind: 'draft', draftId: openIntent.draftId } + return { kind: "draft", draftId: openIntent.draftId }; } export default function HostWorkspaceLayout() { - const router = useRouter() - const consumedIntentRef = useRef(null) + const router = useRouter(); + const consumedIntentRef = useRef(null); const params = useLocalSearchParams<{ - serverId?: string | string[] - workspaceId?: string | string[] - }>() + serverId?: string | string[]; + workspaceId?: string | string[]; + }>(); const globalParams = useGlobalSearchParams<{ - open?: string | string[] - }>() - const serverId = getParamValue(params.serverId) - const workspaceValue = getParamValue(params.workspaceId) - const workspaceId = workspaceValue ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? '') : '' - const openValue = getParamValue(globalParams.open) + open?: string | string[]; + }>(); + const serverId = getParamValue(params.serverId); + const workspaceValue = getParamValue(params.workspaceId); + const workspaceId = workspaceValue + ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") + : ""; + const openValue = getParamValue(globalParams.open); useEffect(() => { if (!openValue) { - return + return; } - const consumptionKey = `${serverId}:${workspaceId}:${openValue}` + const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; if (consumedIntentRef.current === consumptionKey) { - return + return; } - consumedIntentRef.current = consumptionKey + consumedIntentRef.current = consumptionKey; - const openIntent = parseWorkspaceOpenIntent(openValue) + const openIntent = parseWorkspaceOpenIntent(openValue); const route = openIntent ? prepareWorkspaceTab({ serverId, workspaceId, target: getOpenIntentTarget(openIntent), - pin: openIntent.kind === 'agent', + pin: openIntent.kind === "agent", }) - : buildHostWorkspaceRoute(serverId, workspaceId) + : buildHostWorkspaceRoute(serverId, workspaceId); - router.replace(route as any) - }, [openValue, router, serverId, workspaceId]) + router.replace(route as any); + }, [openValue, router, serverId, workspaceId]); if (openValue) { - return null + return null; } return ( @@ -83,5 +85,5 @@ export default function HostWorkspaceLayout() { serverId={serverId} workspaceId={workspaceId} /> - ) + ); } diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 3cff19b69..bab7d68c7 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,82 +1,82 @@ -import { useEffect, useSyncExternalStore, useState } from 'react' -import { usePathname, useRouter } from 'expo-router' -import { useHosts } from '@/runtime/host-runtime' -import { shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon' -import { buildHostRootRoute } from '@/utils/host-routes' -import { StartupSplashScreen } from '@/screens/startup-splash-screen' -import { WelcomeScreen } from '@/components/welcome-screen' -import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' +import { useEffect, useSyncExternalStore, useState } from "react"; +import { usePathname, useRouter } from "expo-router"; +import { useHosts } from "@/runtime/host-runtime"; +import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; +import { buildHostRootRoute } from "@/utils/host-routes"; +import { StartupSplashScreen } from "@/screens/startup-splash-screen"; +import { WelcomeScreen } from "@/components/welcome-screen"; +import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { shouldRedirectToWelcome, shouldWaitOnStartupRace, WELCOME_ROUTE, -} from '@/app-support/index-startup' +} from "@/app-support/index-startup"; -const STARTUP_TIMEOUT_MS = 30_000 +const STARTUP_TIMEOUT_MS = 30_000; function useAnyHostOnline(serverIds: string[]): string | null { - const runtime = getHostRuntimeStore() + const runtime = getHostRuntimeStore(); return useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => { - let firstOnlineServerId: string | null = null - let firstOnlineAt: string | null = null + let firstOnlineServerId: string | null = null; + let firstOnlineAt: string | null = null; for (const serverId of serverIds) { - const snapshot = runtime.getSnapshot(serverId) - const lastOnlineAt = snapshot?.lastOnlineAt ?? null + const snapshot = runtime.getSnapshot(serverId); + const lastOnlineAt = snapshot?.lastOnlineAt ?? null; if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { - continue + continue; } if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { - firstOnlineAt = lastOnlineAt - firstOnlineServerId = serverId + firstOnlineAt = lastOnlineAt; + firstOnlineServerId = serverId; } } - return firstOnlineServerId + return firstOnlineServerId; }, () => { - let firstOnlineServerId: string | null = null - let firstOnlineAt: string | null = null + let firstOnlineServerId: string | null = null; + let firstOnlineAt: string | null = null; for (const serverId of serverIds) { - const snapshot = runtime.getSnapshot(serverId) - const lastOnlineAt = snapshot?.lastOnlineAt ?? null + const snapshot = runtime.getSnapshot(serverId); + const lastOnlineAt = snapshot?.lastOnlineAt ?? null; if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { - continue + continue; } if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { - firstOnlineAt = lastOnlineAt - firstOnlineServerId = serverId + firstOnlineAt = lastOnlineAt; + firstOnlineServerId = serverId; } } - return firstOnlineServerId - } - ) + return firstOnlineServerId; + }, + ); } export default function Index() { - const router = useRouter() - const pathname = usePathname() - const daemons = useHosts() - const [hasTimedOut, setHasTimedOut] = useState(false) - const isDesktopStartupRace = shouldUseDesktopDaemon() - const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId)) + const router = useRouter(); + const pathname = usePathname(); + const daemons = useHosts(); + const [hasTimedOut, setHasTimedOut] = useState(false); + const isDesktopStartupRace = shouldUseDesktopDaemon(); + const onlineServerId = useAnyHostOnline(daemons.map((daemon) => daemon.serverId)); useEffect(() => { const timer = setTimeout(() => { - setHasTimedOut(true) - }, STARTUP_TIMEOUT_MS) + setHasTimedOut(true); + }, STARTUP_TIMEOUT_MS); return () => { - clearTimeout(timer) - } - }, []) + clearTimeout(timer); + }; + }, []); useEffect(() => { if (!onlineServerId) { - return + return; } - if (pathname !== '/' && pathname !== '') { - return + if (pathname !== "/" && pathname !== "") { + return; } - router.replace(buildHostRootRoute(onlineServerId) as any) - }, [onlineServerId, pathname, router]) + router.replace(buildHostRootRoute(onlineServerId) as any); + }, [onlineServerId, pathname, router]); useEffect(() => { if ( @@ -88,10 +88,10 @@ export default function Index() { daemonCount: daemons.length, }) ) { - return + return; } - router.replace(WELCOME_ROUTE as any) - }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]) + router.replace(WELCOME_ROUTE as any); + }, [daemons.length, hasTimedOut, isDesktopStartupRace, onlineServerId, pathname, router]); if ( shouldWaitOnStartupRace({ @@ -102,12 +102,12 @@ export default function Index() { pathname, }) ) { - return + return ; } if (!onlineServerId) { - return + return ; } - return null + return null; } diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index 8bf7aceb3..5c716e4db 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -11,10 +11,7 @@ import { NameHostModal } from "@/components/name-host-modal"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { connectToDaemon } from "@/utils/test-daemon-connection"; import { ConnectionOfferSchema } from "@server/shared/connection-offer"; -import { - buildHostRootRoute, - buildHostSettingsRoute, -} from "@/utils/host-routes"; +import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes"; const styles = StyleSheet.create((theme) => ({ container: { @@ -148,8 +145,7 @@ export default function PairScanScreen() { targetServerId?: string; }>(); const source = typeof params.source === "string" ? params.source : "settings"; - const sourceServerId = - typeof params.sourceServerId === "string" ? params.sourceServerId : null; + const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null; const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null; const daemons = useHosts(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl, renameHost } = useHostMutations(); @@ -157,15 +153,22 @@ export default function PairScanScreen() { const [permission, requestPermission] = useCameraPermissions(); const [isPairing, setIsPairing] = useState(false); const lastScannedRef = useRef(null); - const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); + const [pendingNameHost, setPendingNameHost] = useState<{ + serverId: string; + hostname: string | null; + } | null>(null); const pendingNameHostname = useSessionStore( useCallback( (state) => { if (!pendingNameHost) return null; - return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; + return ( + state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? + pendingNameHost.hostname ?? + null + ); }, - [pendingNameHost] - ) + [pendingNameHost], + ), ); const returnToSource = useCallback( @@ -190,7 +193,7 @@ export default function PairScanScreen() { router.replace(buildHostSettingsRoute(settingsServerId) as any); } }, - [router, source, sourceServerId, targetServerId] + [router, source, sourceServerId, targetServerId], ); const closeToSource = useCallback(() => { @@ -238,7 +241,10 @@ export default function PairScanScreen() { if (targetServerId && offer.serverId !== targetServerId) { lastScannedRef.current = null; - Alert.alert("Wrong daemon", `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`); + Alert.alert( + "Wrong daemon", + `That QR code belongs to ${offer.serverId}, not ${targetServerId}.`, + ); return; } @@ -270,7 +276,7 @@ export default function PairScanScreen() { setIsPairing(false); } }, - [daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl] + [daemons, isPairing, pendingNameHost, returnToSource, targetServerId, upsertDaemonFromOfferUrl], ); if (Platform.OS === "web") { @@ -334,10 +340,7 @@ export default function PairScanScreen() { Allow camera access to scan the pairing QR code from your daemon. - void requestPermission()} - > + void requestPermission()}> Grant permission @@ -356,9 +359,7 @@ export default function PairScanScreen() { - - Point your camera at the pairing QR code. - + Point your camera at the pairing QR code. {isPairing ? ( Pairing… diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index a0bbe5a3d..9af600d3e 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -18,9 +18,7 @@ export default function LegacySettingsRoute() { return null; } if (preferences.serverId) { - const match = daemons.find( - (daemon) => daemon.serverId === preferences.serverId - ); + const match = daemons.find((daemon) => daemon.serverId === preferences.serverId); if (match) { return match.serverId; } diff --git a/packages/app/src/app/welcome.tsx b/packages/app/src/app/welcome.tsx index ead2f1c1e..a3a29cd7a 100644 --- a/packages/app/src/app/welcome.tsx +++ b/packages/app/src/app/welcome.tsx @@ -1,5 +1,5 @@ -import { WelcomeScreen } from '@/components/welcome-screen' +import { WelcomeScreen } from "@/components/welcome-screen"; export default function WelcomeRoute() { - return + return ; } diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts index 688fc0e2b..f170a4ecc 100644 --- a/packages/app/src/attachments/local-file-attachment-store.ts +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -86,10 +86,7 @@ export function createLocalFileAttachmentStore(params: { storageType: Extract; baseDirectoryName: string; resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise; - releasePreviewUrl?: (input: { - attachment: AttachmentMetadata; - url: string; - }) => Promise; + releasePreviewUrl?: (input: { attachment: AttachmentMetadata; url: string }) => Promise; }): AttachmentStore { const baseDirectory = FileSystem.cacheDirectory ? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/` @@ -201,7 +198,7 @@ export function createLocalFileAttachmentStore(params: { await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, { idempotent: true, }); - }) + }), ); }, }; diff --git a/packages/app/src/attachments/service.ts b/packages/app/src/attachments/service.ts index 0fdcf82c9..51a608966 100644 --- a/packages/app/src/attachments/service.ts +++ b/packages/app/src/attachments/service.ts @@ -47,7 +47,7 @@ export async function persistAttachmentFromFileUri(input: { } export async function encodeAttachmentsForSend( - attachments: readonly AttachmentMetadata[] | undefined + attachments: readonly AttachmentMetadata[] | undefined, ): Promise | undefined> { if (!attachments || attachments.length === 0) { return undefined; @@ -69,18 +69,16 @@ export async function encodeAttachmentsForSend( }); return null; } - }) + }), ); const valid = encoded.filter( - (entry): entry is { data: string; mimeType: string } => entry !== null + (entry): entry is { data: string; mimeType: string } => entry !== null, ); return valid.length > 0 ? valid : undefined; } -export async function resolveAttachmentPreviewUrl( - attachment: AttachmentMetadata -): Promise { +export async function resolveAttachmentPreviewUrl(attachment: AttachmentMetadata): Promise { const store = await getAttachmentStore(); return await store.resolvePreviewUrl({ attachment }); } @@ -97,7 +95,7 @@ export async function releaseAttachmentPreviewUrl(input: { } export async function deleteAttachments( - attachments: readonly AttachmentMetadata[] | undefined + attachments: readonly AttachmentMetadata[] | undefined, ): Promise { if (!attachments || attachments.length === 0) { return; @@ -113,7 +111,7 @@ export async function deleteAttachments( error, }); } - }) + }), ); } diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts index 98a873f87..55a884113 100644 --- a/packages/app/src/attachments/store.ts +++ b/packages/app/src/attachments/store.ts @@ -13,15 +13,11 @@ async function createAttachmentStore(): Promise { return createDesktopAttachmentStore(); } - const { createIndexedDbAttachmentStore } = await import( - "./web/indexeddb-attachment-store" - ); + const { createIndexedDbAttachmentStore } = await import("./web/indexeddb-attachment-store"); return createIndexedDbAttachmentStore(); } - const { createNativeFileAttachmentStore } = await import( - "./native/native-file-attachment-store" - ); + const { createNativeFileAttachmentStore } = await import("./native/native-file-attachment-store"); return createNativeFileAttachmentStore(); } diff --git a/packages/app/src/attachments/use-attachment-preview-url.ts b/packages/app/src/attachments/use-attachment-preview-url.ts index 7ccead12f..553ebfea1 100644 --- a/packages/app/src/attachments/use-attachment-preview-url.ts +++ b/packages/app/src/attachments/use-attachment-preview-url.ts @@ -1,12 +1,9 @@ import { useEffect, useRef, useState } from "react"; import type { AttachmentMetadata } from "@/attachments/types"; -import { - releaseAttachmentPreviewUrl, - resolveAttachmentPreviewUrl, -} from "@/attachments/service"; +import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service"; export function useAttachmentPreviewUrl( - attachment: AttachmentMetadata | null | undefined + attachment: AttachmentMetadata | null | undefined, ): string | null { const [url, setUrl] = useState(null); const activeAttachmentRef = useRef(null); @@ -52,12 +49,7 @@ export function useAttachmentPreviewUrl( url: currentUrl, }); }; - }, [ - attachment?.id, - attachment?.storageType, - attachment?.storageKey, - attachment?.mimeType, - ]); + }, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]); return url; } diff --git a/packages/app/src/attachments/web/indexeddb-attachment-store.ts b/packages/app/src/attachments/web/indexeddb-attachment-store.ts index 5f0f9aea9..38c3b2368 100644 --- a/packages/app/src/attachments/web/indexeddb-attachment-store.ts +++ b/packages/app/src/attachments/web/indexeddb-attachment-store.ts @@ -53,7 +53,7 @@ function openAttachmentDb(): Promise { function runTx( db: IDBDatabase, mode: IDBTransactionMode, - run: (store: IDBObjectStore) => IDBRequest + run: (store: IDBObjectStore) => IDBRequest, ): Promise { return new Promise((resolve, reject) => { const transaction = db.transaction(STORE_NAME, mode); @@ -78,7 +78,10 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m const source = input.source; if (source.kind === "blob") { const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type); - const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType); + const blob = + source.blob.type === mimeType + ? source.blob + : source.blob.slice(0, source.blob.size, mimeType); return { blob, mimeType }; } @@ -104,7 +107,7 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m async function loadBlob(db: IDBDatabase, id: string): Promise { const record = await runTx(db, "readonly", (store) => - store.get(id) + store.get(id), ); if (!record?.blob) { throw new Error(`Attachment ${id} was not found in IndexedDB.`); @@ -125,7 +128,7 @@ export function createIndexedDbAttachmentStore(): AttachmentStore { try { await runTx(db, "readwrite", (store) => - store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord) + store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord), ); } finally { db.close(); @@ -184,7 +187,9 @@ export function createIndexedDbAttachmentStore(): AttachmentStore { const cursorRequest = store.openCursor(); cursorRequest.onerror = () => { - reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store.")); + reject( + cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."), + ); }; cursorRequest.onsuccess = () => { diff --git a/packages/app/src/components/active-processes.tsx b/packages/app/src/components/active-processes.tsx index 2f9769552..84a55954a 100644 --- a/packages/app/src/components/active-processes.tsx +++ b/packages/app/src/components/active-processes.tsx @@ -68,16 +68,16 @@ const styles = StyleSheet.create((theme) => ({ backButtonText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: '600', - textAlign: 'center', + fontWeight: "600", + textAlign: "center", }, scrollView: { paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], }, processItem: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -104,7 +104,7 @@ const styles = StyleSheet.create((theme) => ({ processText: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, - fontWeight: '500', + fontWeight: "500", }, processTextActive: { color: theme.colors.primaryForeground, @@ -147,20 +147,21 @@ export function ActiveProcesses({ onPress={onSelectOrchestrator} style={({ pressed }) => [ styles.processItem, - viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive, + viewMode === "orchestrator" ? styles.processItemActive : styles.processItemInactive, pressed && { opacity: 0.7 }, ]} > - Orchestrator + + Orchestrator + {/* Agent pills */} {agents.map((agent) => { - const isActive = viewMode === 'agent' && activeAgentId === agent.id; + const isActive = viewMode === "agent" && activeAgentId === agent.id; return ( - {agent.id.substring(0, 8)} + + {agent.id.substring(0, 8)} + - + {agent.currentModeId && ( - + )} ); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 1144cfa5d..efa95f27d 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -1,15 +1,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import type { ReactNode } from "react"; import { createPortal } from "react-dom"; -import { - Modal, - Platform, - Pressable, - ScrollView, - Text, - TextInput, - View, -} from "react-native"; +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 { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root"; @@ -121,8 +113,7 @@ export function AdaptiveModalSheet({ testID, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const sheetRef = useRef(null); const dismissingForVisibilityRef = useRef(false); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); @@ -148,19 +139,14 @@ export function AdaptiveModalSheet({ onClose(); } }, - [onClose] + [onClose], ); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( - + ), - [] + [], ); if (isMobile) { @@ -180,11 +166,7 @@ export function AdaptiveModalSheet({ > {title} - + @@ -209,11 +191,7 @@ export function AdaptiveModalSheet({ {title} - + @@ -254,13 +232,12 @@ export function AdaptiveModalSheet({ */ export const AdaptiveTextInput = forwardRef( function AdaptiveTextInput(props, ref) { - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; if (isMobile) { return ; } return ; - } + }, ); diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index 1d9984d70..35bc56255 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -60,8 +60,17 @@ export function AddHostMethodModal({ }, [onPasteLink]); return ( - - + + Direct connection @@ -79,7 +88,11 @@ export function AddHostMethodModal({ ) : null} - + Paste pairing link diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index ba8f8fb5d..2a64ebd66 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -60,8 +60,8 @@ function formatTechnicalTransportDetails(details: Array): string .map((value) => normalizeTransportMessage(value)) .filter((value): value is string => Boolean(value)) .map((value) => value.trim()) - .filter((value) => value.length > 0) - ) + .filter((value) => value.length > 0), + ), ); if (unique.length === 0) return null; @@ -78,7 +78,10 @@ function formatTechnicalTransportDetails(details: Array): string return unique.join(" — "); } -function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: string; detail: string | null; raw: string | null } { +function buildConnectionFailureCopy( + endpoint: string, + error: unknown, +): { title: string; detail: string | null; raw: string | null } { const title = `We failed to connect to ${endpoint}.`; const raw = (() => { @@ -109,8 +112,13 @@ function buildConnectionFailureCopy(endpoint: string, error: unknown): { title: detail = "Host not found. Check the hostname and try again."; } else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) { detail = "Host is unreachable. Check your network and firewall."; - } else if (rawLower.includes("certificate") || rawLower.includes("tls") || rawLower.includes("ssl")) { - detail = "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access."; + } else if ( + rawLower.includes("certificate") || + rawLower.includes("tls") || + rawLower.includes("ssl") + ) { + detail = + "TLS error. Direct connections use an unencrypted local connection. Use relay for remote access."; } else if (raw) { detail = "Unable to connect. Check the host/port and that the daemon is reachable."; } else { @@ -125,15 +133,25 @@ export interface AddHostModalProps { onClose: () => void; targetServerId?: string; onCancel?: () => void; - onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; + onSaved?: (result: { + profile: HostProfile; + serverId: string; + hostname: string | null; + isNewHost: boolean; + }) => void; } -export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServerId }: AddHostModalProps) { +export function AddHostModal({ + visible, + onClose, + onCancel, + onSaved, + targetServerId, +}: AddHostModalProps) { const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertDirectConnection } = useHostMutations(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const hostInputRef = useRef(null); @@ -220,10 +238,24 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved, targetServer } finally { setIsSaving(false); } - }, [daemons, endpointRaw, handleClose, isMobile, isSaving, onSaved, targetServerId, upsertDirectConnection]); + }, [ + daemons, + endpointRaw, + handleClose, + isMobile, + isSaving, + onSaved, + targetServerId, + upsertDirectConnection, + ]); return ( - + Enter the address of a Paseo server. diff --git a/packages/app/src/components/agent-activity.tsx b/packages/app/src/components/agent-activity.tsx index b2f806cc1..5757c7c70 100644 --- a/packages/app/src/components/agent-activity.tsx +++ b/packages/app/src/components/agent-activity.tsx @@ -1,76 +1,77 @@ -import { useState } from 'react'; -import { View, Text, Pressable } from 'react-native'; -import { StyleSheet } from 'react-native-unistyles'; +import { useState } from "react"; +import { View, Text, Pressable } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; -import type { AgentActivity, GroupedTextMessage, MergedToolCall, SessionUpdate } from '@/types/agent-activity'; +import type { + AgentActivity, + GroupedTextMessage, + MergedToolCall, + SessionUpdate, +} from "@/types/agent-activity"; interface AgentActivityItemProps { item: GroupedTextMessage | MergedToolCall | AgentActivity; } function formatTimestamp(date: Date): string { - return new Intl.DateTimeFormat('en-US', { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', + return new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", hour12: true, }).format(date); } function getToolIcon(toolKind?: string): string { switch (toolKind) { - case 'read': - return '📖'; - case 'edit': - return '✏️'; - case 'delete': - return '🗑️'; - case 'move': - return '📦'; - case 'search': - return '🔍'; - case 'execute': - return '▶️'; - case 'think': - return '💭'; - case 'fetch': - return '🌐'; - case 'switch_mode': - return '🔄'; + case "read": + return "📖"; + case "edit": + return "✏️"; + case "delete": + return "🗑️"; + case "move": + return "📦"; + case "search": + return "🔍"; + case "execute": + return "▶️"; + case "think": + return "💭"; + case "fetch": + return "🌐"; + case "switch_mode": + return "🔄"; default: - return '🔧'; + return "🔧"; } } function getStatusColor(status?: string): string { switch (status) { - case 'pending': - return '#9ca3af'; - case 'in_progress': - return '#fbbf24'; - case 'completed': - return '#22c55e'; - case 'failed': - return '#ef4444'; + case "pending": + return "#9ca3af"; + case "in_progress": + return "#fbbf24"; + case "completed": + return "#22c55e"; + case "failed": + return "#ef4444"; default: - return '#6b7280'; + return "#6b7280"; } } function GroupedTextItem({ item }: { item: GroupedTextMessage }) { - const isThought = item.messageType === 'thought'; + const isThought = item.messageType === "thought"; return ( {formatTimestamp(item.startTimestamp)} - {isThought && ( - 💭 Thinking - )} - - {item.text} - + {isThought && 💭 Thinking} + {item.text} ); } @@ -80,28 +81,20 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) { return ( - setIsExpanded(!isExpanded)} - style={stylesheet.toolHeader} - > + setIsExpanded(!isExpanded)} style={stylesheet.toolHeader}> - - {formatTimestamp(item.startTimestamp)} - + {formatTimestamp(item.startTimestamp)} {getToolIcon(item.toolKind)} {item.title} {item.status} - {isExpanded ? '▼' : '▶'} + {isExpanded ? "▼" : "▶"} {isExpanded && ( @@ -109,23 +102,17 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) { {item.input && ( Input: - - {JSON.stringify(item.input, null, 2)} - + {JSON.stringify(item.input, null, 2)} )} {item.output && ( Output: - - {JSON.stringify(item.output, null, 2)} - + {JSON.stringify(item.output, null, 2)} )} {!item.input && !item.output && ( - - No details available - + No details available )} )} @@ -136,36 +123,26 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) { function PlanItem({ update, timestamp }: { update: SessionUpdate; timestamp: Date }) { const [isExpanded, setIsExpanded] = useState(true); - if (update.kind !== 'plan') { + if (update.kind !== "plan") { return null; } return ( - setIsExpanded(!isExpanded)} - style={stylesheet.planHeader} - > + setIsExpanded(!isExpanded)} style={stylesheet.planHeader}> {formatTimestamp(timestamp)} - - 📋 Tasks ({update.entries.length}) - + 📋 Tasks ({update.entries.length}) - {isExpanded ? '▼' : '▶'} + {isExpanded ? "▼" : "▶"} {isExpanded && ( {update.entries.map((entry, idx) => ( - - {entry.status === 'completed' ? '✓' : entry.status === 'in_progress' ? '⏳' : '○'} + + {entry.status === "completed" ? "✓" : entry.status === "in_progress" ? "⏳" : "○"} {entry.content} @@ -181,10 +158,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim return ( - setShowDrawer(!showDrawer)} - style={stylesheet.unknownHeader} - > + setShowDrawer(!showDrawer)} style={stylesheet.unknownHeader}> {formatTimestamp(timestamp)} {update.kind} @@ -193,9 +167,7 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim {showDrawer && ( - - {JSON.stringify(update, null, 2)} - + {JSON.stringify(update, null, 2)} )} @@ -204,12 +176,12 @@ function UnknownActivityItem({ update, timestamp }: { update: SessionUpdate; tim export function AgentActivityItem({ item }: AgentActivityItemProps) { // Grouped text message - if ('kind' in item && item.kind === 'grouped_text') { + if ("kind" in item && item.kind === "grouped_text") { return ; } // Merged tool call - if ('kind' in item && item.kind === 'merged_tool_call') { + if ("kind" in item && item.kind === "merged_tool_call") { return ; } @@ -218,12 +190,12 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) { const update = activity.update; // Tasks - if (update.kind === 'plan') { + if (update.kind === "plan") { return ; } // Available commands update - if (update.kind === 'available_commands_update') { + if (update.kind === "available_commands_update") { return ( {formatTimestamp(activity.timestamp)} @@ -235,13 +207,11 @@ export function AgentActivityItem({ item }: AgentActivityItemProps) { } // Current mode update - if (update.kind === 'current_mode_update') { + if (update.kind === "current_mode_update") { return ( {formatTimestamp(activity.timestamp)} - - Mode changed to: {update.currentModeId} - + Mode changed to: {update.currentModeId} ); } @@ -284,26 +254,26 @@ const stylesheet = StyleSheet.create((theme) => ({ }, thoughtText: { color: theme.colors.foregroundMuted, - fontStyle: 'italic', + fontStyle: "italic", }, toolCard: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[2], - overflow: 'hidden', + overflow: "hidden", }, toolHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", padding: theme.spacing[3], }, toolHeaderLeft: { flex: 1, }, toolTitleRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], marginTop: theme.spacing[1], }, @@ -355,18 +325,18 @@ const stylesheet = StyleSheet.create((theme) => ({ emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - fontStyle: 'italic', + fontStyle: "italic", }, planCard: { backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[2], - overflow: 'hidden', + overflow: "hidden", }, planHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", padding: theme.spacing[3], }, planHeaderLeft: { @@ -384,8 +354,8 @@ const stylesheet = StyleSheet.create((theme) => ({ padding: theme.spacing[3], }, planEntry: { - flexDirection: 'row', - alignItems: 'flex-start', + flexDirection: "row", + alignItems: "flex-start", gap: theme.spacing[2], marginBottom: theme.spacing[2], }, @@ -406,7 +376,7 @@ const stylesheet = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[2], - overflow: 'hidden', + overflow: "hidden", }, unknownHeader: { padding: theme.spacing[3], @@ -417,7 +387,7 @@ const stylesheet = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, marginTop: theme.spacing[2], - alignSelf: 'flex-start', + alignSelf: "flex-start", }, unknownBadgeText: { color: theme.colors.foreground, diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index 7484dc5fa..4f89d1ebd 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -1,13 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactElement, ReactNode } from "react"; -import { - View, - Text, - Pressable, - TextInput, - ActivityIndicator, - Platform, -} from "react-native"; +import { View, Text, Pressable, TextInput, ActivityIndicator, Platform } from "react-native"; import type { StyleProp, ViewStyle, TextProps } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { @@ -17,7 +10,18 @@ import { BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated from "react-native-reanimated"; -import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native"; +import { + ChevronDown, + ChevronRight, + Pencil, + Check, + X, + Bot, + Brain, + ShieldCheck, + ShieldAlert, + ShieldOff, +} from "lucide-react-native"; import { theme as defaultTheme } from "@/styles/theme"; import type { AgentMode, @@ -107,19 +111,14 @@ export function DropdownField({ testID={testID} style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]} > - + {value || placeholder} {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : null} - {!errorMessage && helperText ? ( - {helperText} - ) : null} + {!errorMessage && helperText ? {helperText} : null} ); } @@ -176,7 +175,7 @@ export function SelectField({ onPress(); } }, - [getWebKey, onPress, preventWebDefault] + [getWebKey, onPress, preventWebDefault], ); const normalizedValue = (value ?? "").trim(); @@ -184,9 +183,7 @@ export function SelectField({ const hasConcreteValue = normalizedValue.length > 0 && (normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder); - const displayText = hasConcreteValue - ? normalizedValue - : (normalizedPlaceholder || "Select..."); + const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select..."; return ( @@ -231,12 +228,7 @@ interface DropdownSheetProps { } function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) { - return ( - - ); + return ; } export function DropdownSheet({ @@ -267,19 +259,14 @@ export function DropdownSheet({ onClose(); } }, - [onClose] + [onClose], ); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( - + ), - [] + [], ); return ( @@ -450,16 +437,14 @@ export function FormSelectTrigger({ onPress(); } }, - [getWebKey, onPress, preventWebDefault] + [getWebKey, onPress, preventWebDefault], ); const normalizedValue = (value ?? "").trim(); const normalizedPlaceholder = (placeholder ?? "").trim(); const hasConcreteValue = normalizedValue.length > 0 && (normalizedPlaceholder.length === 0 || normalizedValue !== normalizedPlaceholder); - const displayText = hasConcreteValue - ? normalizedValue - : (normalizedPlaceholder || "Select..."); + const displayText = hasConcreteValue ? normalizedValue : normalizedPlaceholder || "Select..."; return ( {icon ? {icon} : null} - {showLabel ? ( - {label} - ) : null} + {showLabel ? {label} : null} {isLoading ? ( ) : ( @@ -540,7 +523,7 @@ export function AgentConfigRow({ id: def.id, label: def.label, })), - [providerDefinitions] + [providerDefinitions], ); const modeSelectOptions: ComboSelectOption[] = useMemo(() => { @@ -554,9 +537,7 @@ export function AgentConfigRow({ }, [modeOptions]); const modelSelectOptions: ComboSelectOption[] = useMemo(() => { - const opts: ComboSelectOption[] = [ - { id: "", label: "Auto" }, - ]; + const opts: ComboSelectOption[] = [{ id: "", label: "Auto" }]; for (const model of models) { opts.push({ id: model.id, @@ -572,7 +553,7 @@ export function AgentConfigRow({ id: option.id, label: option.label, })), - [thinkingOptions] + [thinkingOptions], ); const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : ""); @@ -609,7 +590,9 @@ export function AgentConfigRow({ disabled={disabled} isLoading={isModelLoading} onSelect={onSelectModel} - icon={} + icon={ + + } showLabel={false} testID="draft-model-select" /> @@ -638,7 +621,9 @@ export function AgentConfigRow({ placeholder="Select..." disabled={disabled} onSelect={onSelectThinkingOption} - icon={} + icon={ + + } showLabel={false} /> @@ -664,7 +649,7 @@ export function AssistantDropdown({ const anchorRef = useRef(null); const selectedDefinition = providerDefinitions.find( - (definition) => definition.id === selectedProvider + (definition) => definition.id === selectedProvider, ); const options = useMemo( @@ -673,7 +658,7 @@ export function AssistantDropdown({ id: def.id, label: def.label, })), - [providerDefinitions] + [providerDefinitions], ); const handleOpen = useCallback(() => setIsOpen(true), []); @@ -720,9 +705,9 @@ export function PermissionsDropdown({ const hasOptions = modeOptions.length > 0; const selectedModeLabel = hasOptions - ? modeOptions.find((mode) => mode.id === selectedMode)?.label ?? + ? (modeOptions.find((mode) => mode.id === selectedMode)?.label ?? modeOptions[0]?.label ?? - "Default" + "Default") : "Automatic"; const options = useMemo( @@ -732,7 +717,7 @@ export function PermissionsDropdown({ label: mode.label, description: mode.description, })), - [modeOptions] + [modeOptions], ); const handleOpen = useCallback(() => { @@ -751,9 +736,7 @@ export function PermissionsDropdown({ onPress={handleOpen} disabled={disabled || !hasOptions} helperText={ - hasOptions - ? undefined - : "This assistant does not expose selectable permissions." + hasOptions ? undefined : "This assistant does not expose selectable permissions." } controlRef={anchorRef} /> @@ -795,7 +778,7 @@ export function ModelDropdown({ const anchorRef = useRef(null); const selectedLabel = selectedModel - ? models.find((model) => model.id === selectedModel)?.label ?? selectedModel + ? (models.find((model) => model.id === selectedModel)?.label ?? selectedModel) : "Automatic"; const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic"; const helperText = error @@ -834,7 +817,7 @@ export function ModelDropdown({ onSelect(id); } }, - [onClear, onSelect] + [onClear, onSelect], ); return ( @@ -882,7 +865,7 @@ export function WorkingDirectoryDropdown({ const options = useMemo( () => suggestedPaths.map((path) => ({ id: path, label: path })), - [suggestedPaths] + [suggestedPaths], ); const handleOpen = useCallback(() => setIsOpen(true), []); @@ -957,9 +940,7 @@ export function ToggleRow({ {label} - {description ? ( - {description} - ) : null} + {description ? {description} : null} ); @@ -1052,9 +1033,7 @@ export function GitOptionsSection({ - onWorktreeModeChange(isCreateMode ? "none" : "create") - } + onPress={() => onWorktreeModeChange(isCreateMode ? "none" : "create")} disabled={isLoading} style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]} > @@ -1077,9 +1056,7 @@ export function GitOptionsSection({ - onWorktreeModeChange(isAttachMode ? "none" : "attach") - } + onPress={() => onWorktreeModeChange(isAttachMode ? "none" : "attach")} disabled={isLoading} style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]} > @@ -1143,8 +1120,15 @@ export function GitOptionsSection({ placeholderTextColor={defaultTheme.colors.foregroundMuted} onSubmitEditing={handleConfirmEdit} /> - - + + @@ -1159,17 +1143,11 @@ export function GitOptionsSection({ ) : null} - {baseBranchError ? ( - {baseBranchError} - ) : null} + {baseBranchError ? {baseBranchError} : null} - {repoError ? ( - {repoError} - ) : null} + {repoError ? {repoError} : null} - {gitValidationError ? ( - {gitValidationError} - ) : null} + {gitValidationError ? {gitValidationError} : null} ); } diff --git a/packages/app/src/components/agent-input-area.status-controls.test.ts b/packages/app/src/components/agent-input-area.status-controls.test.ts index 0fa896ebd..93b51a4ab 100644 --- a/packages/app/src/components/agent-input-area.status-controls.test.ts +++ b/packages/app/src/components/agent-input-area.status-controls.test.ts @@ -1,31 +1,31 @@ -import { describe, expect, it } from 'vitest' -import { resolveStatusControlMode } from './agent-input-area.status-controls' +import { describe, expect, it } from "vitest"; +import { resolveStatusControlMode } from "./agent-input-area.status-controls"; -describe('resolveStatusControlMode', () => { - it('uses ready mode when no controlled status controls are provided', () => { - expect(resolveStatusControlMode(undefined)).toBe('ready') - }) +describe("resolveStatusControlMode", () => { + it("uses ready mode when no controlled status controls are provided", () => { + expect(resolveStatusControlMode(undefined)).toBe("ready"); + }); - it('uses draft mode when controlled status controls are provided', () => { + it("uses draft mode when controlled status controls are provided", () => { expect( resolveStatusControlMode({ providerDefinitions: [], - selectedProvider: 'codex', + selectedProvider: "codex", onSelectProvider: () => undefined, modeOptions: [], - selectedMode: '', + selectedMode: "", onSelectMode: () => undefined, models: [], - selectedModel: '', + selectedModel: "", onSelectModel: () => undefined, isModelLoading: false, allProviderModels: new Map(), isAllModelsLoading: false, onSelectProviderAndModel: () => undefined, thinkingOptions: [], - selectedThinkingOptionId: '', + selectedThinkingOptionId: "", onSelectThinkingOption: () => undefined, - }) - ).toBe('draft') - }) -}) + }), + ).toBe("draft"); + }); +}); diff --git a/packages/app/src/components/agent-input-area.status-controls.ts b/packages/app/src/components/agent-input-area.status-controls.ts index ba51e1ca7..c791d558f 100644 --- a/packages/app/src/components/agent-input-area.status-controls.ts +++ b/packages/app/src/components/agent-input-area.status-controls.ts @@ -1,5 +1,5 @@ -import type { DraftAgentStatusBarProps } from './agent-status-bar' +import type { DraftAgentStatusBarProps } from "./agent-status-bar"; export function resolveStatusControlMode(statusControls?: DraftAgentStatusBarProps) { - return statusControls ? 'draft' : 'ready' + return statusControls ? "draft" : "ready"; } diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 6934c835e..750bb68eb 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1,82 +1,90 @@ -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 { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native' -import Animated from 'react-native-reanimated' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { useIsFocused } from '@react-navigation/native' -import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' -import { generateMessageId, type StreamItem } from '@/types/stream' -import { AgentStatusBar, DraftAgentStatusBar, type DraftAgentStatusBarProps } from './agent-status-bar' -import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker' -import { useSessionStore } from '@/stores/session-store' +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 { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; +import Animated from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useIsFocused } from "@react-navigation/native"; +import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { generateMessageId, type StreamItem } from "@/types/stream"; +import { + AgentStatusBar, + DraftAgentStatusBar, + type DraftAgentStatusBarProps, +} from "./agent-status-bar"; +import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; +import { useSessionStore } from "@/stores/session-store"; import { MessageInput, type MessagePayload, type ImageAttachment, type MessageInputRef, -} from './message-input' -import { Theme } from '@/styles/theme' -import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query' -import { encodeImages } from '@/utils/encode-images' -import { focusWithRetries } from '@/utils/web-focus' -import { useVoiceOptional } from '@/contexts/voice-context' -import { useToast } from '@/contexts/toast-context' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Shortcut } from '@/components/ui/shortcut' -import { Autocomplete } from '@/components/ui/autocomplete' -import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete' -import { useHostRuntimeAgentDirectoryStatus, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' +} from "./message-input"; +import { Theme } from "@/styles/theme"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import { encodeImages } from "@/utils/encode-images"; +import { focusWithRetries } from "@/utils/web-focus"; +import { useVoiceOptional } from "@/contexts/voice-context"; +import { useToast } from "@/contexts/toast-context"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Shortcut } from "@/components/ui/shortcut"; +import { Autocomplete } from "@/components/ui/autocomplete"; +import { useAgentAutocomplete } from "@/hooks/use-agent-autocomplete"; +import { + useHostRuntimeAgentDirectoryStatus, + useHostRuntimeClient, + useHostRuntimeIsConnected, +} from "@/runtime/host-runtime"; import { deleteAttachments, persistAttachmentFromBlob, persistAttachmentFromFileUri, -} from '@/attachments/service' -import { resolveStatusControlMode } from '@/components/agent-input-area.status-controls' -import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation' -import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' -import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler' -import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher' +} from "@/attachments/service"; +import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls"; +import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation"; +import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; type QueuedMessage = { - id: string - text: string - images?: ImageAttachment[] -} + id: string; + text: string; + images?: ImageAttachment[]; +}; -type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]) +type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]); interface AgentInputAreaProps { - agentId: string - serverId: string - onSubmitMessage?: (payload: MessagePayload) => Promise + agentId: string; + serverId: string; + onSubmitMessage?: (payload: MessagePayload) => Promise; /** Externally controlled loading state. When true, disables the submit button. */ - isSubmitLoading?: boolean + isSubmitLoading?: boolean; /** When true, blurs the input immediately when submitting. */ - blurOnSubmit?: boolean - value: string - onChangeText: (text: string) => void - images: ImageAttachment[] - onChangeImages: (updater: ImageListUpdater) => void - clearDraft: (lifecycle: 'sent' | 'abandoned') => void + blurOnSubmit?: boolean; + value: string; + onChangeText: (text: string) => void; + images: ImageAttachment[]; + onChangeImages: (updater: ImageListUpdater) => void; + clearDraft: (lifecycle: "sent" | "abandoned") => void; /** When true, auto-focuses the text input on web. */ - autoFocus?: boolean + autoFocus?: boolean; /** Callback to expose the addImages function to parent components */ - onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void + onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void; /** Optional draft context for listing commands before an agent exists. */ - commandDraftConfig?: DraftCommandConfig + commandDraftConfig?: DraftCommandConfig; /** Called when a message is about to be sent (any path: keyboard, dictation, queued). */ - onMessageSent?: () => void - onComposerHeightChange?: (height: number) => void - onAttentionInputFocus?: () => void - onAttentionPromptSend?: () => void + onMessageSent?: () => void; + onComposerHeightChange?: (height: number) => void; + onAttentionInputFocus?: () => void; + onAttentionPromptSend?: () => void; /** Controlled status controls rendered in input area (draft flows). */ - statusControls?: DraftAgentStatusBarProps + statusControls?: DraftAgentStatusBarProps; } -const EMPTY_ARRAY: readonly QueuedMessage[] = [] -const DESKTOP_MESSAGE_PLACEHOLDER = 'Message the agent, tag @files, or use /commands and /skills' -const MOBILE_MESSAGE_PLACEHOLDER = 'Message, @files, /commands' +const EMPTY_ARRAY: readonly QueuedMessage[] = []; +const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills"; +const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands"; export function AgentInputArea({ agentId, @@ -98,54 +106,54 @@ export function AgentInputArea({ onAttentionPromptSend, statusControls, }: AgentInputAreaProps) { - markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) - const { theme } = useUnistyles() - const buttonIconSize = Platform.OS === 'web' ? theme.iconSize.md : theme.iconSize.lg - const insets = useSafeAreaInsets() - const isScreenFocused = useIsFocused() + markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`); + const { theme } = useUnistyles(); + const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg; + const insets = useSafeAreaInsets(); + const isScreenFocused = useIsFocused(); - const client = useHostRuntimeClient(serverId) - const isConnected = useHostRuntimeIsConnected(serverId) - const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId) - const toast = useToast() - const voice = useVoiceOptional() + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId); + const toast = useToast(); + const voice = useVoiceOptional(); const isDictationReady = isConnected && - (agentDirectoryStatus === 'ready' || - agentDirectoryStatus === 'revalidating' || - agentDirectoryStatus === 'error_after_ready') + (agentDirectoryStatus === "ready" || + agentDirectoryStatus === "revalidating" || + agentDirectoryStatus === "error_after_ready"); - const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) + const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)); const queuedMessagesRaw = useSessionStore((state) => - state.sessions[serverId]?.queuedMessages?.get(agentId) - ) - const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY + state.sessions[serverId]?.queuedMessages?.get(agentId), + ); + const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; - const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages) - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail) - const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead) + const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); const isDesktopWebBreakpoint = - Platform.OS === 'web' && - UnistylesRuntime.breakpoint !== 'xs' && - UnistylesRuntime.breakpoint !== 'sm' + Platform.OS === "web" && + UnistylesRuntime.breakpoint !== "xs" && + UnistylesRuntime.breakpoint !== "sm"; const messagePlaceholder = isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER - : MOBILE_MESSAGE_PLACEHOLDER - const userInput = value - const setUserInput = onChangeText - const selectedImages = images - const setSelectedImages = onChangeImages - const [cursorIndex, setCursorIndex] = useState(0) - const [isProcessing, setIsProcessing] = useState(false) - const [isCancellingAgent, setIsCancellingAgent] = useState(false) - const [sendError, setSendError] = useState(null) - const [isMessageInputFocused, setIsMessageInputFocused] = useState(false) - const messageInputRef = useRef(null) + : MOBILE_MESSAGE_PLACEHOLDER; + const userInput = value; + const setUserInput = onChangeText; + const selectedImages = images; + const setSelectedImages = onChangeImages; + const [cursorIndex, setCursorIndex] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [isCancellingAgent, setIsCancellingAgent] = useState(false); + const [sendError, setSendError] = useState(null); + const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); + const messageInputRef = useRef(null); const keyboardHandlerIdRef = useRef( - `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}` - ) + `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, + ); const autocomplete = useAgentAutocomplete({ userInput, @@ -155,405 +163,414 @@ export function AgentInputArea({ agentId, draftConfig: commandDraftConfig, onAutocompleteApplied: () => { - messageInputRef.current?.focus() + messageInputRef.current?.focus(); }, - }) + }); // Clear send error when user edits the input useEffect(() => { if (sendError && userInput) { - setSendError(null) + setSendError(null); } - }, [userInput, sendError]) + }, [userInput, sendError]); useEffect(() => { - setCursorIndex((current) => Math.min(current, userInput.length)) - }, [userInput.length]) + setCursorIndex((current) => Math.min(current, userInput.length)); + }, [userInput.length]); - const { pickImages } = useImageAttachmentPicker() - const agentIdRef = useRef(agentId) + const { pickImages } = useImageAttachmentPicker(); + const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef< ((agentId: string, text: string, images?: ImageAttachment[]) => Promise) | null - >(null) - const onSubmitMessageRef = useRef(onSubmitMessage) + >(null); + const onSubmitMessageRef = useRef(onSubmitMessage); // Expose addImages function to parent for drag-and-drop support - const addImages = useCallback((images: ImageAttachment[]) => { - setSelectedImages((prev) => [...prev, ...images]) - }, [setSelectedImages]) + const addImages = useCallback( + (images: ImageAttachment[]) => { + setSelectedImages((prev) => [...prev, ...images]); + }, + [setSelectedImages], + ); useEffect(() => { - onAddImages?.(addImages) - }, [addImages, onAddImages]) + onAddImages?.(addImages); + }, [addImages, onAddImages]); - const submitMessage = useCallback(async (text: string, images?: ImageAttachment[]) => { - onMessageSent?.() - if (onSubmitMessageRef.current) { - await onSubmitMessageRef.current({ text, images }) - return - } - if (!sendAgentMessageRef.current) { - throw new Error('Host is not connected') - } - await sendAgentMessageRef.current(agentIdRef.current, text, images) - }, [onMessageSent]) + const submitMessage = useCallback( + async (text: string, images?: ImageAttachment[]) => { + onMessageSent?.(); + if (onSubmitMessageRef.current) { + await onSubmitMessageRef.current({ text, images }); + return; + } + if (!sendAgentMessageRef.current) { + throw new Error("Host is not connected"); + } + await sendAgentMessageRef.current(agentIdRef.current, text, images); + }, + [onMessageSent], + ); useEffect(() => { - agentIdRef.current = agentId - }, [agentId]) + agentIdRef.current = agentId; + }, [agentId]); useEffect(() => { sendAgentMessageRef.current = async ( agentId: string, text: string, - images?: ImageAttachment[] + images?: ImageAttachment[], ) => { if (!client) { - throw new Error('Host is not connected') + throw new Error("Host is not connected"); } - const clientMessageId = generateMessageId() + const clientMessageId = generateMessageId(); const userMessage: StreamItem = { - kind: 'user_message', + kind: "user_message", id: clientMessageId, text, timestamp: new Date(), ...(images && images.length > 0 ? { images } : {}), - } + }; // Append to head if streaming (keeps the user message with the current // turn so late text_deltas still find the existing assistant_message). // Otherwise append to tail. const currentHead = useSessionStore .getState() - .sessions[serverId]?.agentStreamHead?.get(agentId) + .sessions[serverId]?.agentStreamHead?.get(agentId); if (currentHead && currentHead.length > 0) { setAgentStreamHead(serverId, (prev) => { - const head = prev.get(agentId) || [] - const updated = new Map(prev) - updated.set(agentId, [...head, userMessage]) - return updated - }) + const head = prev.get(agentId) || []; + const updated = new Map(prev); + updated.set(agentId, [...head, userMessage]); + return updated; + }); } else { setAgentStreamTail(serverId, (prev) => { - const currentStream = prev.get(agentId) || [] - const updated = new Map(prev) - updated.set(agentId, [...currentStream, userMessage]) - return updated - }) + const currentStream = prev.get(agentId) || []; + const updated = new Map(prev); + updated.set(agentId, [...currentStream, userMessage]); + return updated; + }); } - const imagesData = await encodeImages(images) + const imagesData = await encodeImages(images); await client.sendAgentMessage(agentId, text, { messageId: clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), - }) - onAttentionPromptSend?.() - } - }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]) + }); + onAttentionPromptSend?.(); + }; + }, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead]); useEffect(() => { - onSubmitMessageRef.current = onSubmitMessage - }, [onSubmitMessage]) + onSubmitMessageRef.current = onSubmitMessage; + }, [onSubmitMessage]); - const isAgentRunning = agent?.status === 'running' - const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0 + const isAgentRunning = agent?.status === "running"; + const agentUpdatedAtMs = agent?.updatedAt?.getTime() ?? 0; - const prevIsAgentRunningRef = useRef(isAgentRunning) - const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs) + const prevIsAgentRunningRef = useRef(isAgentRunning); + const latestAgentUpdatedAtRef = useRef(agentUpdatedAtMs); useEffect(() => { - const previousUpdatedAt = latestAgentUpdatedAtRef.current + const previousUpdatedAt = latestAgentUpdatedAtRef.current; if (agentUpdatedAtMs < previousUpdatedAt) { if (isProcessing && !isAgentRunning) { - prevIsAgentRunningRef.current = false - setIsProcessing(false) + prevIsAgentRunningRef.current = false; + setIsProcessing(false); } - return + return; } - const wasRunning = prevIsAgentRunningRef.current - let shouldClearProcessing = false + const wasRunning = prevIsAgentRunningRef.current; + let shouldClearProcessing = false; if (isProcessing) { - const hasEnteredRunning = !wasRunning && isAgentRunning + const hasEnteredRunning = !wasRunning && isAgentRunning; const hasFreshRunningUpdateWhileRunning = - wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt - const hasStoppedRunning = wasRunning && !isAgentRunning + wasRunning && isAgentRunning && agentUpdatedAtMs > previousUpdatedAt; + const hasStoppedRunning = wasRunning && !isAgentRunning; shouldClearProcessing = - hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning + hasEnteredRunning || hasFreshRunningUpdateWhileRunning || hasStoppedRunning; } - prevIsAgentRunningRef.current = isAgentRunning - latestAgentUpdatedAtRef.current = agentUpdatedAtMs + prevIsAgentRunningRef.current = isAgentRunning; + latestAgentUpdatedAtRef.current = agentUpdatedAtMs; if (shouldClearProcessing) { - setIsProcessing(false) + setIsProcessing(false); } - }, [agentUpdatedAtMs, isAgentRunning, isProcessing]) + }, [agentUpdatedAtMs, isAgentRunning, isProcessing]); const updateQueue = useCallback( (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { setQueuedMessages(serverId, (prev: Map) => { - const next = new Map(prev) - next.set(agentId, updater(prev.get(agentId) ?? [])) - return next - }) + const next = new Map(prev); + next.set(agentId, updater(prev.get(agentId) ?? [])); + return next; + }); }, - [agentId, serverId, setQueuedMessages] - ) + [agentId, serverId, setQueuedMessages], + ); function queueMessage(message: string, imageAttachments?: ImageAttachment[]) { - const trimmedMessage = message.trim() - if (!trimmedMessage && !imageAttachments?.length) return + const trimmedMessage = message.trim(); + if (!trimmedMessage && !imageAttachments?.length) return; const newItem = { id: generateMessageId(), text: trimmedMessage, images: imageAttachments, - } + }; setQueuedMessages(serverId, (prev: Map) => { - const next = new Map(prev) - next.set(agentId, [...(prev.get(agentId) ?? []), newItem]) - return next - }) + const next = new Map(prev); + next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); + return next; + }); - setUserInput('') - setSelectedImages([]) + setUserInput(""); + setSelectedImages([]); } async function sendMessageWithContent( message: string, imageAttachments?: ImageAttachment[], - forceSend?: boolean + forceSend?: boolean, ) { - const trimmedMessage = message.trim() - if (!trimmedMessage && !imageAttachments?.length) return + const trimmedMessage = message.trim(); + if (!trimmedMessage && !imageAttachments?.length) return; // When the parent controls submission (e.g. draft agent creation), let it // decide what to do even if the socket is currently disconnected (so we // don't no-op and lose deterministic error handling in the UI/tests). - if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return + if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; - if (agent?.status === 'running' && !forceSend) { - queueMessage(trimmedMessage, imageAttachments) - return + if (agent?.status === "running" && !forceSend) { + queueMessage(trimmedMessage, imageAttachments); + return; } // Clear input optimistically before awaiting server ack. // Save values so we can restore on error. - const savedImages = imageAttachments + const savedImages = imageAttachments; if (!onSubmitMessageRef.current) { - setUserInput('') - setSelectedImages([]) + setUserInput(""); + setSelectedImages([]); } - setSendError(null) - setIsProcessing(true) + setSendError(null); + setIsProcessing(true); try { - await submitMessage(trimmedMessage, imageAttachments) - clearDraft('sent') + await submitMessage(trimmedMessage, imageAttachments); + clearDraft("sent"); } catch (error) { - console.error('[AgentInput] Failed to send message:', error) + console.error("[AgentInput] Failed to send message:", error); // Restore input so the user never loses their message if (!onSubmitMessageRef.current) { - setUserInput(trimmedMessage) + setUserInput(trimmedMessage); } if (savedImages) { - setSelectedImages(savedImages) + setSelectedImages(savedImages); } - setSendError(error instanceof Error ? error.message : 'Failed to send message') - setIsProcessing(false) + setSendError(error instanceof Error ? error.message : "Failed to send message"); + setIsProcessing(false); } } function handleSubmit(payload: MessagePayload) { if (blurOnSubmit) { - messageInputRef.current?.blur() + messageInputRef.current?.blur(); } - void sendMessageWithContent(payload.text, payload.images, payload.forceSend) + void sendMessageWithContent(payload.text, payload.images, payload.forceSend); } async function handlePickImage() { - const result = await pickImages() + const result = await pickImages(); if (!result?.length) { - return + return; } const newImages = await Promise.all( result.map(async (pickedImage) => { - if (pickedImage.source.kind === 'blob') { + if (pickedImage.source.kind === "blob") { return await persistAttachmentFromBlob({ blob: pickedImage.source.blob, - mimeType: pickedImage.mimeType || 'image/jpeg', + mimeType: pickedImage.mimeType || "image/jpeg", fileName: pickedImage.fileName ?? null, - }) + }); } return await persistAttachmentFromFileUri({ uri: pickedImage.source.uri, - mimeType: pickedImage.mimeType || 'image/jpeg', + mimeType: pickedImage.mimeType || "image/jpeg", fileName: pickedImage.fileName ?? null, - }) - }) - ) - setSelectedImages((prev) => [...prev, ...newImages]) + }); + }), + ); + setSelectedImages((prev) => [...prev, ...newImages]); } function handleRemoveImage(index: number) { setSelectedImages((prev) => { - const removed = prev[index] + const removed = prev[index]; if (removed) { - void deleteAttachments([removed]) + void deleteAttachments([removed]); } - return prev.filter((_, i) => i !== index) - }) + return prev.filter((_, i) => i !== index); + }); } useEffect(() => { if (!isAgentRunning || !isConnected) { - setIsCancellingAgent(false) + setIsCancellingAgent(false); } - }, [isAgentRunning, isConnected]) + }, [isAgentRunning, isConnected]); - const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => { - if (!isScreenFocused) { - return false - } + const handleKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!isScreenFocused) { + return false; + } - switch (action.id) { - case 'message-input.focus': - if (Platform.OS !== 'web') { - messageInputRef.current?.focus() - return true - } + switch (action.id) { + case "message-input.focus": + if (Platform.OS !== "web") { + messageInputRef.current?.focus(); + return true; + } - focusWithRetries({ - focus: () => messageInputRef.current?.focus(), - isFocused: () => { - const el = messageInputRef.current?.getNativeElement?.() ?? null - const active = typeof document !== 'undefined' ? document.activeElement : null - return Boolean(el) && active === el - }, - }) - return true - case 'message-input.dictation-toggle': - messageInputRef.current?.runKeyboardAction('dictation-toggle') - return true - case 'message-input.dictation-cancel': - messageInputRef.current?.runKeyboardAction('dictation-cancel') - return true - case 'message-input.voice-toggle': - messageInputRef.current?.runKeyboardAction('voice-toggle') - return true - case 'message-input.voice-mute-toggle': - messageInputRef.current?.runKeyboardAction('voice-mute-toggle') - return true - default: - return false - } - }, [isScreenFocused]) + focusWithRetries({ + focus: () => messageInputRef.current?.focus(), + isFocused: () => { + const el = messageInputRef.current?.getNativeElement?.() ?? null; + const active = typeof document !== "undefined" ? document.activeElement : null; + return Boolean(el) && active === el; + }, + }); + return true; + case "message-input.dictation-toggle": + messageInputRef.current?.runKeyboardAction("dictation-toggle"); + return true; + case "message-input.dictation-cancel": + messageInputRef.current?.runKeyboardAction("dictation-cancel"); + return true; + case "message-input.voice-toggle": + messageInputRef.current?.runKeyboardAction("voice-toggle"); + return true; + case "message-input.voice-mute-toggle": + messageInputRef.current?.runKeyboardAction("voice-mute-toggle"); + return true; + default: + return false; + } + }, + [isScreenFocused], + ); useKeyboardActionHandler({ handlerId: keyboardHandlerIdRef.current, actions: [ - 'message-input.focus', - 'message-input.dictation-toggle', - 'message-input.dictation-cancel', - 'message-input.voice-toggle', - 'message-input.voice-mute-toggle', + "message-input.focus", + "message-input.dictation-toggle", + "message-input.dictation-cancel", + "message-input.voice-toggle", + "message-input.voice-mute-toggle", ], enabled: isScreenFocused, priority: isMessageInputFocused ? 200 : 100, isActive: () => isScreenFocused, handle: handleKeyboardAction, - }) + }); const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ - mode: 'translate', - }) + mode: "translate", + }); function handleCancelAgent() { - if (!agent || agent.status !== 'running' || isCancellingAgent) { - return + if (!agent || agent.status !== "running" || isCancellingAgent) { + return; } if (!isConnected || !client) { - return + return; } - setIsCancellingAgent(true) - void client.cancelAgent(agentIdRef.current) - messageInputRef.current?.focus() + setIsCancellingAgent(true); + void client.cancelAgent(agentIdRef.current); + messageInputRef.current?.focus(); } - const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false + const isVoiceModeForAgent = voice?.isVoiceModeForAgent(serverId, agentId) ?? false; const handleToggleRealtimeVoice = useCallback(() => { if (!voice || !isConnected || !agent) { - return + return; } if (voice.isVoiceSwitching) { - return + return; } if (voice.isVoiceModeForAgent(serverId, agentId)) { - return + return; } void voice.startVoice(serverId, agentId).catch((error) => { - console.error('[AgentInputArea] Failed to start voice mode', error) + console.error("[AgentInputArea] Failed to start voice mode", error); const message = - error instanceof Error ? error.message : typeof error === 'string' ? error : null + error instanceof Error ? error.message : typeof error === "string" ? error : null; if (message && message.trim().length > 0) { - toast.error(message) + toast.error(message); } - }) - }, [agent, agentId, isConnected, serverId, toast, voice]) + }); + }, [agent, agentId, isConnected, serverId, toast, voice]); function handleEditQueuedMessage(id: string) { - const item = queuedMessages.find((q) => q.id === id) - if (!item) return + const item = queuedMessages.find((q) => q.id === id); + if (!item) return; - updateQueue((current) => current.filter((q) => q.id !== id)) - setUserInput(item.text) - setSelectedImages(item.images ?? []) + updateQueue((current) => current.filter((q) => q.id !== id)); + setUserInput(item.text); + setSelectedImages(item.images ?? []); } async function handleSendQueuedNow(id: string) { - const item = queuedMessages.find((q) => q.id === id) - if (!item) return - if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return + const item = queuedMessages.find((q) => q.id === id); + if (!item) return; + if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; - updateQueue((current) => current.filter((q) => q.id !== id)) + updateQueue((current) => current.filter((q) => q.id !== id)); // Reuse the regular send path; server-side send atomically interrupts any active run. try { - await submitMessage(item.text, item.images) + await submitMessage(item.text, item.images); } catch (error) { - updateQueue((current) => [item, ...current]) - setSendError(error instanceof Error ? error.message : 'Failed to send message') + updateQueue((current) => [item, ...current]); + setSendError(error instanceof Error ? error.message : "Failed to send message"); } } const handleQueue = useCallback((payload: MessagePayload) => { - queueMessage(payload.text, payload.images) - }, []) + queueMessage(payload.text, payload.images); + }, []); - const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0 + const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0; // Handle keyboard navigation for command autocomplete and stop action. const handleCommandKeyPress = useCallback( (event: { key: string; preventDefault: () => void }) => { if ( - event.key === 'Escape' && + event.key === "Escape" && isAgentRunning && !hasSendableContent && !isCancellingAgent && isConnected ) { - event.preventDefault() - handleCancelAgent() - return true + event.preventDefault(); + handleCancelAgent(); + return true; } - return autocomplete.onKeyPress(event) + return autocomplete.onKeyPress(event); }, [ autocomplete, @@ -562,8 +579,8 @@ export function AgentInputArea({ isCancellingAgent, isConnected, handleCancelAgent, - ] - ) + ], + ); const cancelButton = isAgentRunning && !hasSendableContent && !isProcessing ? ( @@ -571,7 +588,7 @@ export function AgentInputArea({ Interrupt - + - ) : null + ) : null; const rightContent = ( @@ -617,21 +634,21 @@ export function AgentInputArea({ Voice mode - + ) : null} {cancelButton} - ) + ); const leftContent = - resolveStatusControlMode(statusControls) === 'draft' && statusControls ? ( + resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( ) : ( - ) + ); return ( { - setCursorIndex(selection.start) + setCursorIndex(selection.start); }} onFocusChange={(focused) => { - setIsMessageInputFocused(focused) + setIsMessageInputFocused(focused); if (focused) { - onAttentionInputFocus?.() + onAttentionInputFocus?.(); } }} onHeightChange={onComposerHeightChange} @@ -727,43 +744,43 @@ export function AgentInputArea({ - ) + ); } -const BUTTON_SIZE = 40 +const BUTTON_SIZE = 40; const styles = StyleSheet.create(((theme: Theme) => ({ container: { - flexDirection: 'column', - position: 'relative', + flexDirection: "column", + position: "relative", }, borderSeparator: { height: theme.borderWidth[1], backgroundColor: theme.colors.border, }, inputAreaContainer: { - position: 'relative', + position: "relative", minHeight: FOOTER_HEIGHT, - marginHorizontal: 'auto', - alignItems: 'center', - width: '100%', - overflow: 'visible', + marginHorizontal: "auto", + alignItems: "center", + width: "100%", + overflow: "visible", padding: theme.spacing[4], }, inputAreaContent: { - width: '100%', + width: "100%", maxWidth: MAX_CONTENT_WIDTH, gap: theme.spacing[3], }, messageInputContainer: { - position: 'relative', - width: '100%', + position: "relative", + width: "100%", }, autocompletePopover: { - position: 'absolute', + position: "absolute", left: 0, right: 0, - bottom: '100%', + bottom: "100%", marginBottom: theme.spacing[3], zIndex: 30, }, @@ -772,20 +789,20 @@ const styles = StyleSheet.create(((theme: Theme) => ({ height: 28, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.red[600], - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, rightControls: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], }, realtimeVoiceButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, realtimeVoiceButtonActive: { backgroundColor: theme.colors.palette.green[600], @@ -795,8 +812,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({ backgroundColor: theme.colors.surface2, }, tooltipRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], }, tooltipText: { @@ -811,13 +828,13 @@ const styles = StyleSheet.create(((theme: Theme) => ({ opacity: 0.5, }, queueContainer: { - flexDirection: 'column', + flexDirection: "column", gap: theme.spacing[2], }, queueItem: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], backgroundColor: theme.colors.surface1, @@ -832,16 +849,16 @@ const styles = StyleSheet.create(((theme: Theme) => ({ fontSize: theme.fontSize.base, }, queueActions: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], }, queueActionButton: { width: 32, height: 32, borderRadius: theme.borderRadius.full, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", backgroundColor: theme.colors.surface2, }, queueSendButton: { @@ -851,4 +868,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({ color: theme.colors.palette.red[500], fontSize: theme.fontSize.sm, }, -})) as any) as Record +})) as any) as Record; diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 4fdd503ea..82c7611b4 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -6,107 +6,107 @@ import { RefreshControl, FlatList, type ListRenderItem, -} from 'react-native' -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 { formatTimeAgo } from '@/utils/time' -import { shortenPath } from '@/utils/shorten-path' -import { type AggregatedAgent } from '@/hooks/use-aggregated-agents' -import { useSessionStore } from '@/stores/session-store' -import { Archive } from 'lucide-react-native' -import { prepareWorkspaceTab } from '@/utils/workspace-navigation' +} from "react-native"; +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 { formatTimeAgo } from "@/utils/time"; +import { shortenPath } from "@/utils/shorten-path"; +import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useSessionStore } from "@/stores/session-store"; +import { Archive } from "lucide-react-native"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; interface AgentListProps { - agents: AggregatedAgent[] - showCheckoutInfo?: boolean - isRefreshing?: boolean - onRefresh?: () => void - selectedAgentId?: string - onAgentSelect?: () => void - listFooterComponent?: ReactElement | null - showAttentionIndicator?: boolean + agents: AggregatedAgent[]; + showCheckoutInfo?: boolean; + isRefreshing?: boolean; + onRefresh?: () => void; + selectedAgentId?: string; + onAgentSelect?: () => void; + listFooterComponent?: ReactElement | null; + showAttentionIndicator?: boolean; } type FlatListItem = - | { type: 'header'; key: string; title: string } - | { type: 'agent'; key: string; agent: AggregatedAgent } + | { type: "header"; key: string; title: string } + | { type: "agent"; key: string; agent: AggregatedAgent }; function deriveDateSectionLabel(lastActivityAt: Date): string { - const now = new Date() - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000) + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000); const activityStart = new Date( lastActivityAt.getFullYear(), lastActivityAt.getMonth(), - lastActivityAt.getDate() - ) + lastActivityAt.getDate(), + ); if (activityStart.getTime() >= todayStart.getTime()) { - return 'Today' + return "Today"; } if (activityStart.getTime() >= yesterdayStart.getTime()) { - return 'Yesterday' + return "Yesterday"; } - const diffTime = todayStart.getTime() - activityStart.getTime() - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)) + const diffTime = todayStart.getTime() - activityStart.getTime(); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (diffDays <= 7) { - return 'This week' + return "This week"; } if (diffDays <= 30) { - return 'This month' + return "This month"; } - return 'Older' + return "Older"; } -function formatStatusLabel(status: AggregatedAgent['status']): string { +function formatStatusLabel(status: AggregatedAgent["status"]): string { switch (status) { - case 'initializing': - return 'Starting' - case 'idle': - return 'Idle' - case 'running': - return 'Running' - case 'error': - return 'Error' - case 'closed': - return 'Closed' + case "initializing": + return "Starting"; + case "idle": + return "Idle"; + case "running": + return "Running"; + case "error": + return "Error"; + case "closed": + return "Closed"; default: - return status + return status; } } function SessionBadge({ label, icon, - tone = 'neutral', + tone = "neutral", }: { - label: string - icon?: ReactElement - tone?: 'neutral' | 'warning' | 'danger' + label: string; + icon?: ReactElement; + tone?: "neutral" | "warning" | "danger"; }) { return ( {icon} {label} - ) + ); } function SessionRow({ @@ -117,19 +117,19 @@ function SessionRow({ onPress, onLongPress, }: { - agent: AggregatedAgent - isMobile: boolean - selectedAgentId?: string - showAttentionIndicator: boolean - onPress: (agent: AggregatedAgent) => void - onLongPress: (agent: AggregatedAgent) => void + agent: AggregatedAgent; + isMobile: boolean; + selectedAgentId?: string; + showAttentionIndicator: boolean; + onPress: (agent: AggregatedAgent) => void; + onLongPress: (agent: AggregatedAgent) => void; }) { - const { theme } = useUnistyles() - const timeAgo = formatTimeAgo(agent.lastActivityAt) - const agentKey = `${agent.serverId}:${agent.id}` - const isSelected = selectedAgentId === agentKey - const statusLabel = formatStatusLabel(agent.status) - const projectPath = shortenPath(agent.cwd) + const { theme } = useUnistyles(); + const timeAgo = formatTimeAgo(agent.lastActivityAt); + const agentKey = `${agent.serverId}:${agent.id}`; + const isSelected = selectedAgentId === agentKey; + const statusLabel = formatStatusLabel(agent.status); + const projectPath = shortenPath(agent.cwd); return ( - {agent.title || 'New session'} + {agent.title || "New session"} {agent.archivedAt ? ( ) : null} - ) + ); } - export function AgentList({ agents, isRefreshing = false, @@ -212,88 +211,88 @@ export function AgentList({ listFooterComponent, showAttentionIndicator = true, }: AgentListProps) { - const { theme } = useUnistyles() - const insets = useSafeAreaInsets() - const [actionAgent, setActionAgent] = useState(null) - const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const [actionAgent, setActionAgent] = useState(null); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const actionClient = useSessionStore((state) => - actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null - ) + actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, + ); - const isActionSheetVisible = actionAgent !== null - const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient) + const isActionSheetVisible = actionAgent !== null; + const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); const handleAgentPress = useCallback( (agent: AggregatedAgent) => { if (isActionSheetVisible) { - return + return; } - const serverId = agent.serverId - const agentId = agent.id + const serverId = agent.serverId; + const agentId = agent.id; - onAgentSelect?.() + onAgentSelect?.(); const route = prepareWorkspaceTab({ serverId, workspaceId: agent.cwd, - target: { kind: 'agent', agentId }, + target: { kind: "agent", agentId }, pin: Boolean(agent.archivedAt), - }) - router.navigate(route as any) + }); + router.navigate(route as any); }, - [isActionSheetVisible, onAgentSelect] - ) + [isActionSheetVisible, onAgentSelect], + ); const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { - setActionAgent(agent) - }, []) + setActionAgent(agent); + }, []); const handleCloseActionSheet = useCallback(() => { - setActionAgent(null) - }, []) + setActionAgent(null); + }, []); const handleArchiveAgent = useCallback(() => { if (!actionAgent || !actionClient) { - return + return; } - void actionClient.archiveAgent(actionAgent.id) - setActionAgent(null) - }, [actionAgent, actionClient]) + void actionClient.archiveAgent(actionAgent.id); + setActionAgent(null); + }, [actionAgent, actionClient]); const flatItems = useMemo((): FlatListItem[] => { - const order = ['Today', 'Yesterday', 'This week', 'This month', 'Older'] as const - const buckets = new Map() + const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; + const buckets = new Map(); for (const agent of agents) { - const label = deriveDateSectionLabel(agent.lastActivityAt) - const existing = buckets.get(label) ?? [] - existing.push(agent) - buckets.set(label, existing) + const label = deriveDateSectionLabel(agent.lastActivityAt); + const existing = buckets.get(label) ?? []; + existing.push(agent); + buckets.set(label, existing); } - const result: FlatListItem[] = [] + const result: FlatListItem[] = []; for (const label of order) { - const data = buckets.get(label) + const data = buckets.get(label); if (!data || data.length === 0) { - continue + continue; } - result.push({ type: 'header', key: `header:${label}`, title: label }) + result.push({ type: "header", key: `header:${label}`, title: label }); for (const agent of data) { - result.push({ type: 'agent', key: `${agent.serverId}:${agent.id}`, agent }) + result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent }); } } - return result - }, [agents]) + return result; + }, [agents]); const renderItem: ListRenderItem = useCallback( ({ item }) => { - if (item.type === 'header') { + if (item.type === "header") { return ( {item.title} - ) + ); } return ( - ) + ); }, - [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator] - ) + [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator], + ); - const keyExtractor = useCallback((item: FlatListItem) => item.key, []) + const keyExtractor = useCallback((item: FlatListItem) => item.key, []); return ( <> @@ -350,7 +349,7 @@ export function AgentList({ > - {isActionDaemonUnavailable ? 'Host offline' : 'Archive this session?'} + {isActionDaemonUnavailable ? "Host offline" : "Archive this session?"} - ) + ); } const styles = StyleSheet.create((theme) => ({ @@ -399,8 +398,8 @@ const styles = StyleSheet.create((theme) => ({ }, sectionHeading: { marginTop: theme.spacing[2], - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[3], paddingHorizontal: theme.spacing[3], marginBottom: theme.spacing[2], @@ -411,8 +410,8 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, row: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: { @@ -429,15 +428,15 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, rowTitleRow: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", gap: theme.spacing[2], }, rowMetaRow: { - flexDirection: 'row', - alignItems: 'center', - flexWrap: 'wrap', + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", gap: theme.spacing[1], marginTop: 2, }, @@ -456,7 +455,7 @@ const styles = StyleSheet.create((theme) => ({ sessionTitle: { flexShrink: 1, fontSize: theme.fontSize.sm, - fontWeight: '400', + fontWeight: "400", color: theme.colors.foreground, opacity: 0.86, }, @@ -464,7 +463,7 @@ const styles = StyleSheet.create((theme) => ({ opacity: 1, }, sessionMetaText: { - maxWidth: '100%', + maxWidth: "100%", fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, @@ -486,11 +485,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, flexShrink: 0, width: 72, - textAlign: 'right' as const, + textAlign: "right" as const, }, badge: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], @@ -498,10 +497,10 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, badgeWarning: { - backgroundColor: 'rgba(245, 158, 11, 0.12)', + backgroundColor: "rgba(245, 158, 11, 0.12)", }, badgeDanger: { - backgroundColor: 'rgba(239, 68, 68, 0.14)', + backgroundColor: "rgba(239, 68, 68, 0.14)", }, badgeText: { fontSize: theme.fontSize.xs, @@ -516,26 +515,26 @@ const styles = StyleSheet.create((theme) => ({ }, sheetOverlay: { flex: 1, - justifyContent: 'flex-end', + justifyContent: "flex-end", }, sheetBackdrop: { - position: 'absolute', + position: "absolute", top: 0, right: 0, bottom: 0, left: 0, - backgroundColor: 'rgba(0,0,0,0.35)', + backgroundColor: "rgba(0,0,0,0.35)", }, sheetContainer: { backgroundColor: theme.colors.surface2, - borderTopLeftRadius: theme.borderRadius['2xl'], - borderTopRightRadius: theme.borderRadius['2xl'], + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], paddingHorizontal: theme.spacing[6], paddingTop: theme.spacing[4], gap: theme.spacing[4], }, sheetHandle: { - alignSelf: 'center', + alignSelf: "center", width: 40, height: 4, borderRadius: theme.borderRadius.full, @@ -546,18 +545,18 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, - textAlign: 'center', + textAlign: "center", }, sheetButtonRow: { - flexDirection: 'row', + flexDirection: "row", gap: theme.spacing[3], }, sheetButton: { flex: 1, borderRadius: theme.borderRadius.lg, paddingVertical: theme.spacing[4], - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, sheetArchiveButton: { backgroundColor: theme.colors.primary, @@ -578,4 +577,4 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.semibold, fontSize: theme.fontSize.base, }, -})) +})); diff --git a/packages/app/src/components/agent-status-bar.test.ts b/packages/app/src/components/agent-status-bar.test.ts index 68d55c0a2..99e65bb46 100644 --- a/packages/app/src/components/agent-status-bar.test.ts +++ b/packages/app/src/components/agent-status-bar.test.ts @@ -1,72 +1,72 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from "vitest"; import { getStatusSelectorHint, normalizeModelId, resolveAgentModelSelection, -} from './agent-status-bar.utils' +} from "./agent-status-bar.utils"; -describe('getStatusSelectorHint', () => { - it('explains what each editable status control does', () => { - expect(getStatusSelectorHint('thinking')).toBe('Thinking mode') - expect(getStatusSelectorHint('model')).toBe('Change model') - expect(getStatusSelectorHint('mode')).toBe('Change permission mode') - }) -}) +describe("getStatusSelectorHint", () => { + it("explains what each editable status control does", () => { + expect(getStatusSelectorHint("thinking")).toBe("Thinking mode"); + expect(getStatusSelectorHint("model")).toBe("Change model"); + expect(getStatusSelectorHint("mode")).toBe("Change permission mode"); + }); +}); -describe('normalizeModelId', () => { - it('treats empty and default values as unset', () => { - expect(normalizeModelId('')).toBeNull() - expect(normalizeModelId(' default ')).toBeNull() - expect(normalizeModelId(undefined)).toBeNull() - }) +describe("normalizeModelId", () => { + it("treats empty and default values as unset", () => { + expect(normalizeModelId("")).toBeNull(); + expect(normalizeModelId(" default ")).toBeNull(); + expect(normalizeModelId(undefined)).toBeNull(); + }); - it('returns trimmed model ids', () => { - expect(normalizeModelId(' gpt-5.1-codex ')).toBe('gpt-5.1-codex') - }) -}) + it("returns trimmed model ids", () => { + expect(normalizeModelId(" gpt-5.1-codex ")).toBe("gpt-5.1-codex"); + }); +}); -describe('resolveAgentModelSelection', () => { - it('prefers runtime model over configured model', () => { +describe("resolveAgentModelSelection", () => { + it("prefers runtime model over configured model", () => { const selection = resolveAgentModelSelection({ models: [ { - id: 'a', - provider: 'codex', - label: 'Model A', - thinkingOptions: [{ id: 'low', label: 'Low' }], - defaultThinkingOptionId: 'low', + id: "a", + provider: "codex", + label: "Model A", + thinkingOptions: [{ id: "low", label: "Low" }], + defaultThinkingOptionId: "low", }, ], - runtimeModelId: 'a', - configuredModelId: 'b', + runtimeModelId: "a", + configuredModelId: "b", explicitThinkingOptionId: null, - }) + }); - expect(selection.activeModelId).toBe('a') - expect(selection.displayModel).toBe('Model A') - expect(selection.selectedThinkingId).toBe('low') - }) + expect(selection.activeModelId).toBe("a"); + expect(selection.displayModel).toBe("Model A"); + expect(selection.selectedThinkingId).toBe("low"); + }); - it('uses explicit thinking option when provided', () => { + it("uses explicit thinking option when provided", () => { const selection = resolveAgentModelSelection({ models: [ { - id: 'a', - provider: 'codex', - label: 'Model A', + id: "a", + provider: "codex", + label: "Model A", thinkingOptions: [ - { id: 'low', label: 'Low' }, - { id: 'high', label: 'High' }, + { id: "low", label: "Low" }, + { id: "high", label: "High" }, ], - defaultThinkingOptionId: 'low', + defaultThinkingOptionId: "low", }, ], - runtimeModelId: 'a', + runtimeModelId: "a", configuredModelId: null, - explicitThinkingOptionId: 'high', - }) + explicitThinkingOptionId: "high", + }); - expect(selection.selectedThinkingId).toBe('high') - expect(selection.displayThinking).toBe('High') - }) -}) + expect(selection.selectedThinkingId).toBe("high"); + expect(selection.displayThinking).toBe("High"); + }); +}); diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 96c0e8353..8ec96b137 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -1,129 +1,130 @@ -import { useCallback, useMemo, useRef, useState } from 'react' -import { View, Text, Platform, Pressable } from 'react-native' -import { StyleSheet, useUnistyles } from 'react-native-unistyles' -import { - Brain, - ChevronDown, - ShieldAlert, - ShieldCheck, - ShieldOff, - -} from 'lucide-react-native' -import { getProviderIcon } from '@/components/provider-icons' -import { CombinedModelSelector } from '@/components/combined-model-selector' -import { useQuery } from '@tanstack/react-query' -import { useSessionStore } from '@/stores/session-store' +import { useCallback, useMemo, useRef, useState } from "react"; +import { View, Text, Platform, Pressable } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Brain, ChevronDown, ShieldAlert, ShieldCheck, ShieldOff } from "lucide-react-native"; +import { getProviderIcon } from "@/components/provider-icons"; +import { CombinedModelSelector } from "@/components/combined-model-selector"; +import { useQuery } from "@tanstack/react-query"; +import { useSessionStore } from "@/stores/session-store"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox' -import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +} from "@/components/ui/dropdown-menu"; +import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { AgentMode, AgentModelDefinition, AgentProvider, -} from '@server/server/agent/agent-sdk-types' -import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest' +} from "@server/server/agent/agent-sdk-types"; +import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeColorTier, type AgentModeIcon, -} from '@server/server/agent/provider-manifest' +} from "@server/server/agent/provider-manifest"; import { getStatusSelectorHint, resolveAgentModelSelection, -} from '@/components/agent-status-bar.utils' +} from "@/components/agent-status-bar.utils"; type StatusOption = { - id: string - label: string -} + id: string; + label: string; +}; -type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking' +type StatusSelector = "provider" | "mode" | "model" | "thinking"; type ControlledAgentStatusBarProps = { - provider: string - providerOptions?: StatusOption[] - selectedProviderId?: string - onSelectProvider?: (providerId: string) => void - modeOptions?: StatusOption[] - selectedModeId?: string - onSelectMode?: (modeId: string) => void - modelOptions?: StatusOption[] - selectedModelId?: string - onSelectModel?: (modelId: string) => void - thinkingOptions?: StatusOption[] - selectedThinkingOptionId?: string - onSelectThinkingOption?: (thinkingOptionId: string) => void - disabled?: boolean - isModelLoading?: boolean -} + provider: string; + providerOptions?: StatusOption[]; + selectedProviderId?: string; + onSelectProvider?: (providerId: string) => void; + modeOptions?: StatusOption[]; + selectedModeId?: string; + onSelectMode?: (modeId: string) => void; + modelOptions?: StatusOption[]; + selectedModelId?: string; + onSelectModel?: (modelId: string) => void; + thinkingOptions?: StatusOption[]; + selectedThinkingOptionId?: string; + onSelectThinkingOption?: (thinkingOptionId: string) => void; + disabled?: boolean; + isModelLoading?: boolean; +}; export interface DraftAgentStatusBarProps { - providerDefinitions: AgentProviderDefinition[] - selectedProvider: AgentProvider - onSelectProvider: (provider: AgentProvider) => void - modeOptions: AgentMode[] - selectedMode: string - onSelectMode: (modeId: string) => void - models: AgentModelDefinition[] - selectedModel: string - onSelectModel: (modelId: string) => void - isModelLoading: boolean - allProviderModels: Map - isAllModelsLoading: boolean - onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void - thinkingOptions: NonNullable - selectedThinkingOptionId: string - onSelectThinkingOption: (thinkingOptionId: string) => void - disabled?: boolean + providerDefinitions: AgentProviderDefinition[]; + selectedProvider: AgentProvider; + onSelectProvider: (provider: AgentProvider) => void; + modeOptions: AgentMode[]; + selectedMode: string; + onSelectMode: (modeId: string) => void; + models: AgentModelDefinition[]; + selectedModel: string; + onSelectModel: (modelId: string) => void; + isModelLoading: boolean; + allProviderModels: Map; + isAllModelsLoading: boolean; + onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void; + thinkingOptions: NonNullable; + selectedThinkingOptionId: string; + onSelectThinkingOption: (thinkingOptionId: string) => void; + disabled?: boolean; } interface AgentStatusBarProps { - agentId: string - serverId: string + agentId: string; + serverId: string; } -function findOptionLabel(options: StatusOption[] | undefined, selectedId: string | undefined, fallback: string) { +function findOptionLabel( + options: StatusOption[] | undefined, + selectedId: string | undefined, + fallback: string, +) { if (!options || options.length === 0) { - return fallback + return fallback; } - const selected = options.find((option) => option.id === selectedId) - return selected?.label ?? fallback + const selected = options.find((option) => option.id === selectedId); + return selected?.label ?? fallback; } const MODE_ICONS = { ShieldCheck, ShieldAlert, ShieldOff, -} as const - +} as const; function getModeIconColor( colorTier: AgentModeColorTier | undefined, - palette: { blue: { 500: string }; green: { 500: string }; amber: { 500: string }; red: { 500: string }; purple: { 500: string } } + palette: { + blue: { 500: string }; + green: { 500: string }; + amber: { 500: string }; + red: { 500: string }; + purple: { 500: string }; + }, ): string { switch (colorTier) { - case 'default': - return palette.blue[500] - case 'safe': - return palette.green[500] - case 'moderate': - return palette.amber[500] - case 'dangerous': - return palette.red[500] - case 'readonly': - return palette.purple[500] + case "default": + return palette.blue[500]; + case "safe": + return palette.green[500]; + case "moderate": + return palette.amber[500]; + case "dangerous": + return palette.red[500]; + case "readonly": + return palette.purple[500]; default: - return palette.blue[500] + return palette.blue[500]; } } - function ControlledStatusBar({ provider, providerOptions, @@ -141,71 +142,83 @@ function ControlledStatusBar({ disabled = false, isModelLoading = false, }: ControlledAgentStatusBarProps) { - const { theme } = useUnistyles() - const isWeb = Platform.OS === 'web' - const [prefsOpen, setPrefsOpen] = useState(false) - const [openSelector, setOpenSelector] = useState(null) + const { theme } = useUnistyles(); + const isWeb = Platform.OS === "web"; + const [prefsOpen, setPrefsOpen] = useState(false); + const [openSelector, setOpenSelector] = useState(null); - const providerAnchorRef = useRef(null) - const modeAnchorRef = useRef(null) - const modelAnchorRef = useRef(null) - const thinkingAnchorRef = useRef(null) + const providerAnchorRef = useRef(null); + const modeAnchorRef = useRef(null); + const modelAnchorRef = useRef(null); + const thinkingAnchorRef = useRef(null); - const canSelectProvider = Boolean(onSelectProvider && providerOptions && providerOptions.length > 0) - const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0) - const canSelectModel = Boolean(onSelectModel) + const canSelectProvider = Boolean( + onSelectProvider && providerOptions && providerOptions.length > 0, + ); + const canSelectMode = Boolean(onSelectMode && modeOptions && modeOptions.length > 0); + const canSelectModel = Boolean(onSelectModel); const canSelectThinking = Boolean( - onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0 - ) + onSelectThinkingOption && thinkingOptions && thinkingOptions.length > 0, + ); - const displayProvider = findOptionLabel(providerOptions, selectedProviderId, 'Provider') - const displayMode = findOptionLabel(modeOptions, selectedModeId, 'Default') + const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider"); + const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default"); const displayModel = isModelLoading && (!modelOptions || modelOptions.length === 0) - ? 'Loading models...' - : findOptionLabel(modelOptions, selectedModelId, 'Auto') - const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto') + ? "Loading models..." + : findOptionLabel(modelOptions, selectedModelId, "Auto"); + const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, "auto"); - const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined - const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null - const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette) - const ProviderIcon = getProviderIcon(provider) + const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined; + const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null; + const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette); + const ProviderIcon = getProviderIcon(provider); const hasAnyControl = Boolean(providerOptions?.length) || Boolean(modeOptions?.length) || canSelectModel || - Boolean(thinkingOptions?.length) + Boolean(thinkingOptions?.length); if (!hasAnyControl) { - return null + return null; } - const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0 + const modelDisabled = disabled || isModelLoading || !modelOptions || modelOptions.length === 0; - const SEARCH_THRESHOLD = 6 + const SEARCH_THRESHOLD = 6; const comboboxProviderOptions = useMemo( () => (providerOptions ?? []).map((o) => ({ id: o.id, label: o.label })), - [providerOptions] - ) + [providerOptions], + ); const comboboxModeOptions = useMemo( () => (modeOptions ?? []).map((o) => ({ id: o.id, label: o.label })), - [modeOptions] - ) + [modeOptions], + ); const comboboxModelOptions = useMemo( () => (modelOptions ?? []).map((o) => ({ id: o.id, label: o.label })), - [modelOptions] - ) + [modelOptions], + ); const comboboxThinkingOptions = useMemo( () => (thinkingOptions ?? []).map((o) => ({ id: o.id, label: o.label })), - [thinkingOptions] - ) + [thinkingOptions], + ); const renderModeOption = useCallback( - ({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => { - const visuals = getModeVisuals(provider, option.id) - const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + const visuals = getModeVisuals(provider, option.id); + const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; return ( } /> - ) + ); }, - [provider, theme.colors.foreground] - ) + [provider, theme.colors.foreground], + ); const handleOpenChange = useCallback( (selector: StatusSelector) => (nextOpen: boolean) => { - setOpenSelector(nextOpen ? selector : null) + setOpenSelector(nextOpen ? selector : null); }, - [] - ) + [], + ); const handleSelectorPress = useCallback( (selector: StatusSelector) => { - handleOpenChange(selector)(openSelector !== selector) + handleOpenChange(selector)(openSelector !== selector); }, - [handleOpenChange, openSelector] - ) + [handleOpenChange, openSelector], + ); return ( @@ -243,11 +256,11 @@ function ControlledStatusBar({ ref={providerAnchorRef} collapsable={false} disabled={disabled || !canSelectProvider} - onPress={() => handleSelectorPress('provider')} + onPress={() => handleSelectorPress("provider")} style={({ pressed, hovered }) => [ styles.modeBadge, hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'provider') && styles.modeBadgePressed, + (pressed || openSelector === "provider") && styles.modeBadgePressed, (disabled || !canSelectProvider) && styles.disabledBadge, ]} accessibilityRole="button" @@ -259,11 +272,11 @@ function ControlledStatusBar({ onSelectProvider?.(id)} searchable={comboboxProviderOptions.length > SEARCH_THRESHOLD} - open={openSelector === 'provider'} - onOpenChange={handleOpenChange('provider')} + open={openSelector === "provider"} + onOpenChange={handleOpenChange("provider")} anchorRef={providerAnchorRef} desktopPlacement="top-start" /> @@ -273,7 +286,7 @@ function ControlledStatusBar({ {modeOptions && modeOptions.length > 0 ? ( <> handleSelectorPress('mode')} + onPress={() => handleSelectorPress("mode")} style={({ pressed, hovered }) => [ styles.modeIconBadge, hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'mode') && styles.modeBadgePressed, + (pressed || openSelector === "mode") && styles.modeBadgePressed, (disabled || !canSelectMode) && styles.disabledBadge, ]} accessibilityRole="button" @@ -302,16 +315,16 @@ function ControlledStatusBar({ - {getStatusSelectorHint('mode')} + {getStatusSelectorHint("mode")} onSelectMode?.(id)} searchable={comboboxModeOptions.length > SEARCH_THRESHOLD} - open={openSelector === 'mode'} - onOpenChange={handleOpenChange('mode')} + open={openSelector === "mode"} + onOpenChange={handleOpenChange("mode")} anchorRef={modeAnchorRef} desktopPlacement="top-start" renderOption={renderModeOption} @@ -322,7 +335,7 @@ function ControlledStatusBar({ {canSelectModel ? ( <> handleSelectorPress('model')} + onPress={() => handleSelectorPress("model")} style={({ pressed, hovered }) => [ styles.modeBadge, hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'model') && styles.modeBadgePressed, + (pressed || openSelector === "model") && styles.modeBadgePressed, modelDisabled && styles.disabledBadge, ]} accessibilityRole="button" @@ -349,16 +362,16 @@ function ControlledStatusBar({ - {getStatusSelectorHint('model')} + {getStatusSelectorHint("model")} onSelectModel?.(id)} searchable={comboboxModelOptions.length > SEARCH_THRESHOLD} - open={openSelector === 'model'} - onOpenChange={handleOpenChange('model')} + open={openSelector === "model"} + onOpenChange={handleOpenChange("model")} anchorRef={modelAnchorRef} desktopPlacement="top-start" /> @@ -368,7 +381,7 @@ function ControlledStatusBar({ {thinkingOptions && thinkingOptions.length > 0 ? ( <> handleSelectorPress('thinking')} + onPress={() => handleSelectorPress("thinking")} style={({ pressed, hovered }) => [ styles.modeBadge, hovered && styles.modeBadgeHovered, - (pressed || openSelector === 'thinking') && styles.modeBadgePressed, + (pressed || openSelector === "thinking") && styles.modeBadgePressed, (disabled || !canSelectThinking) && styles.disabledBadge, ]} accessibilityRole="button" @@ -395,16 +408,16 @@ function ControlledStatusBar({ - {getStatusSelectorHint('thinking')} + {getStatusSelectorHint("thinking")} onSelectThinkingOption?.(id)} searchable={comboboxThinkingOptions.length > SEARCH_THRESHOLD} - open={openSelector === 'thinking'} - onOpenChange={handleOpenChange('thinking')} + open={openSelector === "thinking"} + onOpenChange={handleOpenChange("thinking")} anchorRef={thinkingAnchorRef} desktopPlacement="top-start" /> @@ -415,16 +428,15 @@ function ControlledStatusBar({ <> setPrefsOpen(true)} - style={({ pressed }) => [ - styles.prefsButton, - pressed && styles.prefsButtonPressed, - ]} + style={({ pressed }) => [styles.prefsButton, pressed && styles.prefsButtonPressed]} accessibilityRole="button" accessibilityLabel="Agent preferences" testID="agent-preferences-button" > - {displayModel} + + {displayModel} + 0 ? ( 0 ? ( {modeOptions.map((mode) => { - const visuals = getModeVisuals(provider, mode.id) - const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck + const visuals = getModeVisuals(provider, mode.id); + const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; return ( {mode.label} - ) + ); })} @@ -514,8 +526,8 @@ function ControlledStatusBar({ {canSelectModel ? ( 0 ? ( )} - ) + ); } export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { - const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) + const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const modelsQuery = useQuery({ queryKey: [ - 'providerModels', + "providerModels", serverId, - agent?.provider ?? '__missing_provider__', - agent?.cwd ?? '__missing_cwd__', + agent?.provider ?? "__missing_provider__", + agent?.cwd ?? "__missing_cwd__", ], enabled: Boolean(client && agent?.provider), staleTime: 5 * 60 * 1000, queryFn: async () => { if (!client || !agent) { - throw new Error('Daemon client unavailable') + throw new Error("Daemon client unavailable"); } - const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }) + const payload = await client.listProviderModels(agent.provider, { cwd: agent.cwd }); if (payload.error) { - throw new Error(payload.error) + throw new Error(payload.error); } - return payload.models ?? [] + return payload.models ?? []; }, - }) + }); - const models = modelsQuery.data ?? null + const models = modelsQuery.data ?? null; const displayMode = agent?.availableModes?.find((mode) => mode.id === agent.currentModeId)?.label || agent?.currentModeId || - 'default' + "default"; const modelSelection = resolveAgentModelSelection({ models, runtimeModelId: agent?.runtimeInfo?.model, configuredModelId: agent?.model, explicitThinkingOptionId: agent?.thinkingOptionId, - }) + }); const modeOptions = useMemo(() => { return (agent?.availableModes ?? []).map((mode) => ({ id: mode.id, label: mode.label, - })) - }, [agent?.availableModes]) + })); + }, [agent?.availableModes]); const modelOptions = useMemo(() => { - return (models ?? []).map((model) => ({ id: model.id, label: model.label })) - }, [models]) + return (models ?? []).map((model) => ({ id: model.id, label: model.label })); + }, [models]); const thinkingOptions = useMemo(() => { return (modelSelection.thinkingOptions ?? []).map((option) => ({ id: option.id, label: option.label, - })) - }, [modelSelection.thinkingOptions]) + })); + }, [modelSelection.thinkingOptions]); if (!agent) { - return null + return null; } return ( @@ -654,41 +666,41 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { modeOptions={ modeOptions.length > 0 ? modeOptions - : [{ id: agent.currentModeId ?? '', label: displayMode }] + : [{ id: agent.currentModeId ?? "", label: displayMode }] } selectedModeId={agent.currentModeId ?? undefined} onSelectMode={(modeId) => { if (!client) { - return + return; } void client.setAgentMode(agentId, modeId).catch((error) => { - console.warn('[AgentStatusBar] setAgentMode failed', error) - }) + console.warn("[AgentStatusBar] setAgentMode failed", error); + }); }} modelOptions={modelOptions} selectedModelId={modelSelection.activeModelId ?? undefined} onSelectModel={(modelId) => { if (!client) { - return + return; } void client.setAgentModel(agentId, modelId).catch((error) => { - console.warn('[AgentStatusBar] setAgentModel failed', error) - }) + console.warn("[AgentStatusBar] setAgentModel failed", error); + }); }} thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={(thinkingOptionId) => { if (!client) { - return + return; } void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { - console.warn('[AgentStatusBar] setAgentThinkingOption failed', error) - }) + console.warn("[AgentStatusBar] setAgentThinkingOption failed", error); + }); }} isModelLoading={modelsQuery.isPending || modelsQuery.isFetching} disabled={!client} /> - ) + ); } export function DraftAgentStatusBar({ @@ -710,25 +722,25 @@ export function DraftAgentStatusBar({ onSelectThinkingOption, disabled = false, }: DraftAgentStatusBarProps) { - const isWeb = Platform.OS === 'web' + const isWeb = Platform.OS === "web"; const mappedModeOptions = useMemo(() => { if (modeOptions.length === 0) { - return [{ id: '', label: 'Default' }] + return [{ id: "", label: "Default" }]; } return modeOptions.map((mode) => ({ id: mode.id, label: mode.label, - })) - }, [modeOptions]) + })); + }, [modeOptions]); const mappedThinkingOptions = useMemo(() => { - return thinkingOptions.map((option) => ({ id: option.id, label: option.label })) - }, [thinkingOptions]) + return thinkingOptions.map((option) => ({ id: option.id, label: option.label })); + }, [thinkingOptions]); - const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || '' + const effectiveSelectedMode = selectedMode || mappedModeOptions[0]?.id || ""; const effectiveSelectedThinkingOption = - selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined + selectedThinkingOptionId || mappedThinkingOptions[0]?.id || undefined; if (isWeb) { return ( @@ -753,17 +765,17 @@ export function DraftAgentStatusBar({ disabled={disabled} /> - ) + ); } const providerOptions = providerDefinitions.map((definition) => ({ id: definition.id, label: definition.label, - })) + })); - const modelOptions: StatusOption[] = [{ id: '', label: 'Auto' }] + const modelOptions: StatusOption[] = [{ id: "", label: "Auto" }]; for (const model of models) { - modelOptions.push({ id: model.id, label: model.label }) + modelOptions.push({ id: model.id, label: model.label }); } return ( @@ -784,30 +796,30 @@ export function DraftAgentStatusBar({ onSelectThinkingOption={onSelectThinkingOption} disabled={disabled} /> - ) + ); } const styles = StyleSheet.create((theme) => ({ container: { - flexDirection: 'row', - alignItems: 'flex-end', + flexDirection: "row", + alignItems: "flex-end", gap: theme.spacing[1], }, modeBadge: { height: 28, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'transparent', + flexDirection: "row", + alignItems: "center", + backgroundColor: "transparent", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius['2xl'], + borderRadius: theme.borderRadius["2xl"], }, modeIconBadge: { width: 28, height: 28, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'transparent', + alignItems: "center", + justifyContent: "center", + backgroundColor: "transparent", borderRadius: theme.borderRadius.full, }, modeBadgeHovered: { @@ -831,11 +843,11 @@ const styles = StyleSheet.create((theme) => ({ }, prefsButton: { height: 28, - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius['2xl'], + borderRadius: theme.borderRadius["2xl"], }, prefsButtonPressed: { backgroundColor: theme.colors.surface0, @@ -850,9 +862,9 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, sheetSelect: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", gap: theme.spacing[3], paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], @@ -873,4 +885,4 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, -})) +})); diff --git a/packages/app/src/components/agent-status-bar.utils.ts b/packages/app/src/components/agent-status-bar.utils.ts index 70e27252f..ce048eb5f 100644 --- a/packages/app/src/components/agent-status-bar.utils.ts +++ b/packages/app/src/components/agent-status-bar.utils.ts @@ -1,51 +1,54 @@ -import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types' +import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; -export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking' +export type ExplainedStatusSelector = "mode" | "model" | "thinking"; export function getStatusSelectorHint(selector: ExplainedStatusSelector): string { switch (selector) { - case 'thinking': - return 'Thinking mode' - case 'model': - return 'Change model' - case 'mode': - return 'Change permission mode' + case "thinking": + return "Thinking mode"; + case "model": + return "Change model"; + case "mode": + return "Change permission mode"; } } export function normalizeModelId(modelId: string | null | undefined): string | null { - const normalized = typeof modelId === 'string' ? modelId.trim() : '' - if (!normalized || normalized.toLowerCase() === 'default') { - return null + const normalized = typeof modelId === "string" ? modelId.trim() : ""; + if (!normalized || normalized.toLowerCase() === "default") { + return null; } - return normalized + return normalized; } export function resolveAgentModelSelection(input: { - models: AgentModelDefinition[] | null - runtimeModelId: string | null | undefined - configuredModelId: string | null | undefined - explicitThinkingOptionId: string | null | undefined + models: AgentModelDefinition[] | null; + runtimeModelId: string | null | undefined; + configuredModelId: string | null | undefined; + explicitThinkingOptionId: string | null | undefined; }) { - const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input - const normalizedRuntimeModelId = normalizeModelId(runtimeModelId) - const normalizedConfiguredModelId = normalizeModelId(configuredModelId) - const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId + const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input; + const normalizedRuntimeModelId = normalizeModelId(runtimeModelId); + const normalizedConfiguredModelId = normalizeModelId(configuredModelId); + const preferredModelId = normalizedRuntimeModelId ?? normalizedConfiguredModelId; const selectedModel = - models && preferredModelId ? models.find((model) => model.id === preferredModelId) ?? null : null + models && preferredModelId + ? (models.find((model) => model.id === preferredModelId) ?? null) + : null; - const activeModelId = selectedModel?.id ?? preferredModelId ?? null - const displayModel = selectedModel?.label ?? preferredModelId ?? 'Auto' + const activeModelId = selectedModel?.id ?? preferredModelId ?? null; + const displayModel = selectedModel?.label ?? preferredModelId ?? "Auto"; - const thinkingOptions = selectedModel?.thinkingOptions ?? null + const thinkingOptions = selectedModel?.thinkingOptions ?? null; const selectedThinkingId = - explicitThinkingOptionId && explicitThinkingOptionId !== 'default' + explicitThinkingOptionId && explicitThinkingOptionId !== "default" ? explicitThinkingOptionId - : selectedModel?.defaultThinkingOptionId ?? null - const selectedThinking = thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null + : (selectedModel?.defaultThinkingOptionId ?? null); + const selectedThinking = + thinkingOptions?.find((option) => option.id === selectedThinkingId) ?? null; const displayThinking = selectedThinking?.label ?? - (selectedThinkingId === 'default' ? 'Model default' : selectedThinkingId ?? 'auto') + (selectedThinkingId === "default" ? "Model default" : (selectedThinkingId ?? "auto")); return { selectedModel, @@ -54,5 +57,5 @@ export function resolveAgentModelSelection(input: { thinkingOptions, selectedThinkingId, displayThinking, - } + }; } diff --git a/packages/app/src/components/agent-stream-render-model.ts b/packages/app/src/components/agent-stream-render-model.ts index 057749073..d8a2eeaa7 100644 --- a/packages/app/src/components/agent-stream-render-model.ts +++ b/packages/app/src/components/agent-stream-render-model.ts @@ -128,7 +128,7 @@ function splitOrderedTail(params: { } export function buildAgentStreamRenderModel( - input: BuildAgentStreamRenderModelInput + input: BuildAgentStreamRenderModelInput, ): AgentStreamRenderModel { const strategy = resolveStreamRenderStrategy({ platform: input.platform === "web" ? "web" : "native", diff --git a/packages/app/src/components/agent-stream-render-strategy.test.ts b/packages/app/src/components/agent-stream-render-strategy.test.ts index d74d2aa49..6530705dd 100644 --- a/packages/app/src/components/agent-stream-render-strategy.test.ts +++ b/packages/app/src/components/agent-stream-render-strategy.test.ts @@ -78,7 +78,7 @@ describe("resolveStreamRenderStrategy", () => { resolveBottomAnchorTransportBehavior({ strategy, isViewportSettling: true, - }) + }), ).toEqual({ verificationDelayFrames: 4, verificationRetryMode: "recheck", @@ -95,7 +95,7 @@ describe("resolveStreamRenderStrategy", () => { resolveBottomAnchorTransportBehavior({ strategy, isViewportSettling: true, - }) + }), ).toEqual({ verificationDelayFrames: 0, verificationRetryMode: "rescroll", @@ -172,7 +172,7 @@ describe("neighbor and traversal semantics", () => { strategy: forward, items: chronological, startIndex: forwardStartIndex, - }) + }), ).toBe("assistant-1\n\nassistant-2"); const inverted = resolveStreamRenderStrategy({ @@ -189,7 +189,7 @@ describe("neighbor and traversal semantics", () => { strategy: inverted, items: invertedItems, startIndex: invertedStartIndex, - }) + }), ).toBe("assistant-1\n\nassistant-2"); }); @@ -206,7 +206,7 @@ describe("neighbor and traversal semantics", () => { items, index: 0, relation: "above", - }) + }), ).toBeUndefined(); expect( getStreamNeighborItem({ @@ -214,7 +214,7 @@ describe("neighbor and traversal semantics", () => { items, index: 0, relation: "below", - }) + }), ).toBeUndefined(); }); }); @@ -233,7 +233,7 @@ describe("scroll/bottom calculations", () => { viewportHeight: 300, contentHeight: 1000, threshold: 24, - }) + }), ).toBe(true); expect( isNearBottomForStreamRenderStrategy({ @@ -242,7 +242,7 @@ describe("scroll/bottom calculations", () => { viewportHeight: 300, contentHeight: 1000, threshold: 24, - }) + }), ).toBe(false); }); @@ -259,7 +259,7 @@ describe("scroll/bottom calculations", () => { viewportHeight: 300, contentHeight: 1000, threshold: 24, - }) + }), ).toBe(true); expect( isNearBottomForStreamRenderStrategy({ @@ -268,14 +268,14 @@ describe("scroll/bottom calculations", () => { viewportHeight: 300, contentHeight: 1000, threshold: 24, - }) + }), ).toBe(false); expect( getBottomOffsetForStreamRenderStrategy({ strategy, viewportHeight: 300, contentHeight: 1000, - }) + }), ).toBe(0); }); @@ -290,7 +290,7 @@ describe("scroll/bottom calculations", () => { strategy, viewportHeight: 320, contentHeight: 1000, - }) + }), ).toBe(680); }); }); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 7e23192a4..19b3ef9ea 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -7,13 +7,7 @@ import { useRef, useState, } from "react"; -import { - View, - Text, - Pressable, - Platform, - ActivityIndicator, -} from "react-native"; +import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native"; import Markdown from "react-native-markdown-display"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useMutation } from "@tanstack/react-query"; @@ -94,521 +88,493 @@ export interface AgentStreamViewProps { onOpenWorkspaceFile?: (input: { filePath: string }) => void; } -export const AgentStreamView = forwardRef(function AgentStreamView({ - agentId, - serverId, - agent, - streamItems, - pendingPermissions, - routeBottomAnchorRequest = null, - isAuthoritativeHistoryReady = true, - onOpenWorkspaceFile, -}, ref) { - const viewportRef = useRef(null); - const { theme } = useUnistyles(); - const router = useRouter(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - const streamRenderStrategy = useMemo( - () => - resolveStreamRenderStrategy({ - platform: Platform.OS, - isMobileBreakpoint: isMobile, - }), - [isMobile] - ); - const [isNearBottom, setIsNearBottom] = useState(true); - const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>(new Set()); - const openFileExplorer = usePanelStore((state) => state.openFileExplorer); - const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); - - // Get serverId (fallback to agent's serverId if not provided) - const resolvedServerId = serverId ?? agent.serverId ?? ""; - - const client = useSessionStore( - (state) => state.sessions[resolvedServerId]?.client ?? null - ); - const streamHead = useSessionStore((state) => - state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId) - ); - - const workspaceRoot = agent.cwd?.trim() || ""; - const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot; - const { requestDirectoryListing } = useFileExplorerActions({ - serverId: resolvedServerId, - workspaceId, - workspaceRoot, - }); - // Keep entry/exit animations off on Android due to RN dispatchDraw crashes - // tracked in react-native-reanimated#8422. - const shouldDisableEntryExitAnimations = Platform.OS === "android"; - const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations - ? undefined - : FadeIn.duration(200); - const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations - ? undefined - : FadeOut.duration(200); - - useEffect(() => { - setIsNearBottom(true); - setExpandedInlineToolCallIds(new Set()); - }, [agentId]); - - const handleInlinePathPress = useCallback( - (target: InlinePathTarget) => { - if (!target.path) { - return; - } - - const normalized = normalizeInlinePathTarget(target.path, agent.cwd); - if (!normalized) { - return; - } - - if (normalized.file) { - if (onOpenWorkspaceFile) { - onOpenWorkspaceFile({ filePath: normalized.file }); - return; - } - - const route = prepareWorkspaceTab({ - serverId: resolvedServerId, - workspaceId, - target: { kind: "file", path: normalized.file }, - }); - router.navigate(route as any); - return; - } - - void requestDirectoryListing(normalized.directory, { - recordHistory: false, - setCurrentPath: false, - }); - - setExplorerTabForCheckout({ - serverId: resolvedServerId, - cwd: agent.cwd, - isGit: agent.projectPlacement?.checkout?.isGit ?? true, - tab: "files", - }); - openFileExplorer(); - }, - [ - agent.cwd, - openFileExplorer, - requestDirectoryListing, - resolvedServerId, - router, - setExplorerTabForCheckout, - onOpenWorkspaceFile, - workspaceId, - ] - ); - - const baseRenderModel = useMemo(() => { - return buildAgentStreamRenderModel({ - tail: streamItems, - head: streamHead ?? [], - platform: Platform.OS === "web" ? "web" : "native", - isMobileBreakpoint: isMobile, - }); - }, [isMobile, streamHead, streamItems]); - useImperativeHandle(ref, () => ({ - scrollToBottom(reason = "jump-to-bottom") { - viewportRef.current?.scrollToBottom(reason); - }, - prepareForViewportChange() { - viewportRef.current?.prepareForViewportChange(); - }, - }), []); - - function scrollToBottom() { - viewportRef.current?.scrollToBottom("jump-to-bottom"); - } - - const tightGap = theme.spacing[1]; // 4px - const looseGap = theme.spacing[4]; // 16px - - const getGapBetween = useCallback( - (item: StreamItem | null, belowItem: StreamItem | null) => { - if (!item || !belowItem) { - return 0; - } - - if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { - return tightGap; - } - if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { - return tightGap; - } - if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { - return looseGap; - } - if ( - (item.kind === "user_message" || item.kind === "assistant_message") && - isToolSequenceItem(belowItem) - ) { - return tightGap; - } - if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) { - return tightGap; - } - if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { - return tightGap; - } - return looseGap; - }, - [looseGap, tightGap] - ); - - // --------------------------------------------------------------------------- - // DEBUG: track when render callback deps change - // --------------------------------------------------------------------------- - const debugStreamPrevRef = useRef>({}); - useEffect(() => { - const prev = debugStreamPrevRef.current; - const curr: Record = { - // handleInlinePathPress deps (line 196-205) - "hip.agent.cwd": agent.cwd, - "hip.openFileExplorer": openFileExplorer, - "hip.requestDirectoryListing": requestDirectoryListing, - "hip.resolvedServerId": resolvedServerId, - "hip.router": router, - "hip.setExplorerTabForCheckout": setExplorerTabForCheckout, - "hip.onOpenWorkspaceFile": onOpenWorkspaceFile, - "hip.workspaceId": workspaceId, - // top-level deps - handleInlinePathPress, - "agent.status": agent.status, - streamRenderStrategy, - getGapBetween, +export const AgentStreamView = forwardRef( + function AgentStreamView( + { + agentId, + serverId, + agent, streamItems, - "streamItems.length": streamItems.length, - streamHead, - baseRenderModel, - }; - const changed: string[] = []; - for (const key of Object.keys(curr)) { - if (!Object.is(prev[key], curr[key])) { - changed.push(key); - } - } - if (changed.length > 0 && Object.keys(prev).length > 0) { - console.log("[AgentStreamView] deps changed:", changed.join(", ")); - } - debugStreamPrevRef.current = curr; - }); + pendingPermissions, + routeBottomAnchorRequest = null, + isAuthoritativeHistoryReady = true, + onOpenWorkspaceFile, + }, + ref, + ) { + const viewportRef = useRef(null); + const { theme } = useUnistyles(); + const router = useRouter(); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const streamRenderStrategy = useMemo( + () => + resolveStreamRenderStrategy({ + platform: Platform.OS, + isMobileBreakpoint: isMobile, + }), + [isMobile], + ); + const [isNearBottom, setIsNearBottom] = useState(true); + const [expandedInlineToolCallIds, setExpandedInlineToolCallIds] = useState>( + new Set(), + ); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); - const renderStreamItemContent = useCallback( - ( - item: StreamItem, - index: number, - items: StreamItem[], - seamAboveItem: StreamItem | null = null - ) => { - const handleInlineDetailsExpandedChange = (expanded: boolean) => { - if ( - !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() - ) { + // Get serverId (fallback to agent's serverId if not provided) + const resolvedServerId = serverId ?? agent.serverId ?? ""; + + const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null); + const streamHead = useSessionStore((state) => + state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), + ); + + const workspaceRoot = agent.cwd?.trim() || ""; + const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot; + const { requestDirectoryListing } = useFileExplorerActions({ + serverId: resolvedServerId, + workspaceId, + workspaceRoot, + }); + // Keep entry/exit animations off on Android due to RN dispatchDraw crashes + // tracked in react-native-reanimated#8422. + const shouldDisableEntryExitAnimations = Platform.OS === "android"; + const scrollIndicatorFadeIn = shouldDisableEntryExitAnimations + ? undefined + : FadeIn.duration(200); + const scrollIndicatorFadeOut = shouldDisableEntryExitAnimations + ? undefined + : FadeOut.duration(200); + + useEffect(() => { + setIsNearBottom(true); + setExpandedInlineToolCallIds(new Set()); + }, [agentId]); + + const handleInlinePathPress = useCallback( + (target: InlinePathTarget) => { + if (!target.path) { return; } - setExpandedInlineToolCallIds((previous) => { - const next = new Set(previous); - if (expanded) { - next.add(item.id); - } else { - next.delete(item.id); - } - return next; - }); - }; - switch (item.kind) { - case "user_message": { - const aboveItem = - getStreamNeighborItem({ + const normalized = normalizeInlinePathTarget(target.path, agent.cwd); + if (!normalized) { + return; + } + + if (normalized.file) { + if (onOpenWorkspaceFile) { + onOpenWorkspaceFile({ filePath: normalized.file }); + return; + } + + const route = prepareWorkspaceTab({ + serverId: resolvedServerId, + workspaceId, + target: { kind: "file", path: normalized.file }, + }); + router.navigate(route as any); + return; + } + + void requestDirectoryListing(normalized.directory, { + recordHistory: false, + setCurrentPath: false, + }); + + setExplorerTabForCheckout({ + serverId: resolvedServerId, + cwd: agent.cwd, + isGit: agent.projectPlacement?.checkout?.isGit ?? true, + tab: "files", + }); + openFileExplorer(); + }, + [ + agent.cwd, + openFileExplorer, + requestDirectoryListing, + resolvedServerId, + router, + setExplorerTabForCheckout, + onOpenWorkspaceFile, + workspaceId, + ], + ); + + const baseRenderModel = useMemo(() => { + return buildAgentStreamRenderModel({ + tail: streamItems, + head: streamHead ?? [], + platform: Platform.OS === "web" ? "web" : "native", + isMobileBreakpoint: isMobile, + }); + }, [isMobile, streamHead, streamItems]); + useImperativeHandle( + ref, + () => ({ + scrollToBottom(reason = "jump-to-bottom") { + viewportRef.current?.scrollToBottom(reason); + }, + prepareForViewportChange() { + viewportRef.current?.prepareForViewportChange(); + }, + }), + [], + ); + + function scrollToBottom() { + viewportRef.current?.scrollToBottom("jump-to-bottom"); + } + + const tightGap = theme.spacing[1]; // 4px + const looseGap = theme.spacing[4]; // 16px + + const getGapBetween = useCallback( + (item: StreamItem | null, belowItem: StreamItem | null) => { + if (!item || !belowItem) { + return 0; + } + + if (isUserMessageItem(item) && isUserMessageItem(belowItem)) { + return tightGap; + } + if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) { + return tightGap; + } + if (item.kind === "user_message" && isToolSequenceItem(belowItem)) { + return looseGap; + } + if ( + (item.kind === "user_message" || item.kind === "assistant_message") && + isToolSequenceItem(belowItem) + ) { + return tightGap; + } + if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) { + return tightGap; + } + if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") { + return tightGap; + } + return looseGap; + }, + [looseGap, tightGap], + ); + + // --------------------------------------------------------------------------- + // DEBUG: track when render callback deps change + // --------------------------------------------------------------------------- + const debugStreamPrevRef = useRef>({}); + useEffect(() => { + const prev = debugStreamPrevRef.current; + const curr: Record = { + // handleInlinePathPress deps (line 196-205) + "hip.agent.cwd": agent.cwd, + "hip.openFileExplorer": openFileExplorer, + "hip.requestDirectoryListing": requestDirectoryListing, + "hip.resolvedServerId": resolvedServerId, + "hip.router": router, + "hip.setExplorerTabForCheckout": setExplorerTabForCheckout, + "hip.onOpenWorkspaceFile": onOpenWorkspaceFile, + "hip.workspaceId": workspaceId, + // top-level deps + handleInlinePathPress, + "agent.status": agent.status, + streamRenderStrategy, + getGapBetween, + streamItems, + "streamItems.length": streamItems.length, + streamHead, + baseRenderModel, + }; + const changed: string[] = []; + for (const key of Object.keys(curr)) { + if (!Object.is(prev[key], curr[key])) { + changed.push(key); + } + } + if (changed.length > 0 && Object.keys(prev).length > 0) { + console.log("[AgentStreamView] deps changed:", changed.join(", ")); + } + debugStreamPrevRef.current = curr; + }); + + const renderStreamItemContent = useCallback( + ( + item: StreamItem, + index: number, + items: StreamItem[], + seamAboveItem: StreamItem | null = null, + ) => { + const handleInlineDetailsExpandedChange = (expanded: boolean) => { + if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) { + return; + } + setExpandedInlineToolCallIds((previous) => { + const next = new Set(previous); + if (expanded) { + next.add(item.id); + } else { + next.delete(item.id); + } + return next; + }); + }; + + switch (item.kind) { + case "user_message": { + const aboveItem = + getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "above", + }) ?? + seamAboveItem ?? + undefined; + const belowItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, index, - relation: "above", - }) ?? seamAboveItem ?? undefined; - const belowItem = getStreamNeighborItem({ - strategy: streamRenderStrategy, - items, - index, - relation: "below", - }); - const isFirstInGroup = aboveItem?.kind !== "user_message"; - const isLastInGroup = belowItem?.kind !== "user_message"; - return ( - - ); - } + relation: "below", + }); + const isFirstInGroup = aboveItem?.kind !== "user_message"; + const isLastInGroup = belowItem?.kind !== "user_message"; + return ( + + ); + } - case "assistant_message": - return ( - - ); + case "assistant_message": + return ( + + ); - case "thought": { - const nextItem = getStreamNeighborItem({ - strategy: streamRenderStrategy, - items, - index, - relation: "below", - }); - const isLastInSequence = - nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought"; - return ( - - ); - } - - case "tool_call": { - const { payload } = item; - const nextItem = getStreamNeighborItem({ - strategy: streamRenderStrategy, - items, - index, - relation: "below", - }); - const isLastInSequence = - nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought"; - - if (payload.source === "agent") { - const data = payload.data; + case "thought": { + const nextItem = getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "below", + }); + const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought"; return ( ); } - const data = payload.data; - return ( - - ); + case "tool_call": { + const { payload } = item; + const nextItem = getStreamNeighborItem({ + strategy: streamRenderStrategy, + items, + index, + relation: "below", + }); + const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought"; + + if (payload.source === "agent") { + const data = payload.data; + return ( + + ); + } + + const data = payload.data; + return ( + + ); + } + + case "activity_log": + return ( + + ); + + case "todo_list": + return ; + + case "compaction": + return ; + + default: + return null; + } + }, + [handleInlinePathPress, agent.cwd, streamRenderStrategy], + ); + + const renderStreamItem = useCallback( + ( + item: StreamItem, + index: number, + items: StreamItem[], + seamAboveItem: StreamItem | null = null, + ) => { + const content = renderStreamItemContent(item, index, items, seamAboveItem); + if (!content) { + return null; } - case "activity_log": - return ( - - ); - - case "todo_list": - return ( - - ); - - case "compaction": - return ( - - ); - - default: - return null; - } - }, - [handleInlinePathPress, agent.cwd, streamRenderStrategy] - ); - - const renderStreamItem = useCallback( - ( - item: StreamItem, - index: number, - items: StreamItem[], - seamAboveItem: StreamItem | null = null - ) => { - const content = renderStreamItemContent(item, index, items, seamAboveItem); - if (!content) { - return null; - } - - const nextItem = getStreamNeighborItem({ - strategy: streamRenderStrategy, - items, - index, - relation: "below", - }); - const gapBelow = getGapBetween(item, nextItem ?? null); - const isEndOfAssistantTurn = - item.kind === "assistant_message" && - (nextItem?.kind === "user_message" || - (nextItem === undefined && agent.status !== "running")); - const getTurnContent = () => - collectAssistantTurnContentForStreamRenderStrategy({ + const nextItem = getStreamNeighborItem({ strategy: streamRenderStrategy, items, - startIndex: index, + index, + relation: "below", }); + const gapBelow = getGapBetween(item, nextItem ?? null); + const isEndOfAssistantTurn = + item.kind === "assistant_message" && + (nextItem?.kind === "user_message" || + (nextItem === undefined && agent.status !== "running")); + const getTurnContent = () => + collectAssistantTurnContentForStreamRenderStrategy({ + strategy: streamRenderStrategy, + items, + startIndex: index, + }); - return ( - - {content} - {isEndOfAssistantTurn ? ( - - ) : null} - - ); - }, - [ - getGapBetween, - renderStreamItemContent, - agent.status, - streamRenderStrategy, - ] - ); + return ( + + {content} + {isEndOfAssistantTurn ? : null} + + ); + }, + [getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy], + ); - const pendingPermissionItems = useMemo( - () => - Array.from(pendingPermissions.values()).filter( - (perm) => perm.agentId === agentId - ), - [pendingPermissions, agentId] - ); + const pendingPermissionItems = useMemo( + () => Array.from(pendingPermissions.values()).filter((perm) => perm.agentId === agentId), + [pendingPermissions, agentId], + ); - const showWorkingIndicator = agent.status === "running"; - const renderModel = useMemo(() => { - const pendingPermissionsNode = - pendingPermissionItems.length > 0 ? ( - - {pendingPermissionItems.map((permission) => ( - - ))} + const showWorkingIndicator = agent.status === "running"; + const renderModel = useMemo(() => { + const pendingPermissionsNode = + pendingPermissionItems.length > 0 ? ( + + {pendingPermissionItems.map((permission) => ( + + ))} + + ) : null; + const workingIndicatorNode = showWorkingIndicator ? ( + + ) : null; - const workingIndicatorNode = showWorkingIndicator ? ( - - - - ) : null; - return { - ...baseRenderModel, - boundary: { - ...baseRenderModel.boundary, - historyToHeadGap: getGapBetween( - baseRenderModel.history.at(-1) ?? null, - baseRenderModel.segments.liveHead[0] ?? null - ), - }, - auxiliary: { - pendingPermissions: pendingPermissionsNode, - workingIndicator: workingIndicatorNode, - }, - }; - }, [ - baseRenderModel, - client, - getGapBetween, - pendingPermissionItems, - showWorkingIndicator, - ]); + return { + ...baseRenderModel, + boundary: { + ...baseRenderModel.boundary, + historyToHeadGap: getGapBetween( + baseRenderModel.history.at(-1) ?? null, + baseRenderModel.segments.liveHead[0] ?? null, + ), + }, + auxiliary: { + pendingPermissions: pendingPermissionsNode, + workingIndicator: workingIndicatorNode, + }, + }; + }, [baseRenderModel, client, getGapBetween, pendingPermissionItems, showWorkingIndicator]); - const listEmptyComponent = useMemo(() => { - if ( - renderModel.boundary.hasVirtualizedHistory || - renderModel.boundary.hasMountedHistory || - renderModel.boundary.hasLiveHead || - renderModel.auxiliary.pendingPermissions || - renderModel.auxiliary.workingIndicator - ) { - return null; - } - - return ( - - - Start chatting with this agent... - - - ); - }, [renderModel]); - - const historyItems = renderModel.history; - const liveHeadItems = renderModel.segments.liveHead; - const { boundary, auxiliary } = renderModel; - const lastHistoryItem = historyItems.at(-1) ?? null; - - const historyIndexById = useMemo(() => { - const indexById = new Map(); - historyItems.forEach((item, index) => { - indexById.set(item.id, index); - }); - return indexById; - }, [historyItems]); - - const renderHistoryRow = useCallback( - (item: StreamItem) => { - const historyIndex = historyIndexById.get(item.id); - if (historyIndex === undefined) { + const listEmptyComponent = useMemo(() => { + if ( + renderModel.boundary.hasVirtualizedHistory || + renderModel.boundary.hasMountedHistory || + renderModel.boundary.hasLiveHead || + renderModel.auxiliary.pendingPermissions || + renderModel.auxiliary.workingIndicator + ) { return null; } - return renderStreamItem(item, historyIndex, historyItems); - }, - [historyIndexById, historyItems, renderStreamItem] - ); - const renderHistoryVirtualizedRow = useCallback( - (item) => renderHistoryRow(item), - [renderHistoryRow] - ); - const renderHistoryMountedRow = useCallback( - (item) => renderHistoryRow(item), - [renderHistoryRow] - ); - const renderLiveHeadRow = useCallback( - (item, index, items) => - renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null), - [lastHistoryItem, renderStreamItem] - ); - const renderLiveAuxiliary = useCallback( - () => { + return ( + + Start chatting with this agent... + + ); + }, [renderModel]); + + const historyItems = renderModel.history; + const liveHeadItems = renderModel.segments.liveHead; + const { boundary, auxiliary } = renderModel; + const lastHistoryItem = historyItems.at(-1) ?? null; + + const historyIndexById = useMemo(() => { + const indexById = new Map(); + historyItems.forEach((item, index) => { + indexById.set(item.id, index); + }); + return indexById; + }, [historyItems]); + + const renderHistoryRow = useCallback( + (item: StreamItem) => { + const historyIndex = historyIndexById.get(item.id); + if (historyIndex === undefined) { + return null; + } + return renderStreamItem(item, historyIndex, historyItems); + }, + [historyIndexById, historyItems, renderStreamItem], + ); + + const renderHistoryVirtualizedRow = useCallback< + StreamSegmentRenderers["renderHistoryVirtualizedRow"] + >((item) => renderHistoryRow(item), [renderHistoryRow]); + const renderHistoryMountedRow = useCallback( + (item) => renderHistoryRow(item), + [renderHistoryRow], + ); + const renderLiveHeadRow = useCallback( + (item, index, items) => + renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null), + [lastHistoryItem, renderStreamItem], + ); + const renderLiveAuxiliary = useCallback(() => { if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) { return null; } @@ -625,80 +591,71 @@ export const AgentStreamView = forwardRef ); - }, - [ - auxiliary.pendingPermissions, - auxiliary.workingIndicator, - boundary.hasLiveHead, - tightGap, - ] - ); + }, [auxiliary.pendingPermissions, auxiliary.workingIndicator, boundary.hasLiveHead, tightGap]); - const renderers = useMemo( - () => ({ - renderHistoryVirtualizedRow, - renderHistoryMountedRow, - renderLiveHeadRow, - renderLiveAuxiliary, - }), - [ - renderHistoryVirtualizedRow, - renderHistoryMountedRow, - renderLiveHeadRow, - renderLiveAuxiliary, - ] - ); + const renderers = useMemo( + () => ({ + renderHistoryVirtualizedRow, + renderHistoryMountedRow, + renderLiveHeadRow, + renderLiveAuxiliary, + }), + [ + renderHistoryVirtualizedRow, + renderHistoryMountedRow, + renderLiveHeadRow, + renderLiveAuxiliary, + ], + ); - const streamScrollEnabled = - !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || - expandedInlineToolCallIds.size === 0; + const streamScrollEnabled = + !streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() || + expandedInlineToolCallIds.size === 0; - return ( - - - - {streamRenderStrategy.render({ - agentId, - segments: renderModel.segments, - boundary, - renderers, - listEmptyComponent, - viewportRef, - routeBottomAnchorRequest, - isAuthoritativeHistoryReady, - onNearBottomChange: setIsNearBottom, - scrollEnabled: streamScrollEnabled, - listStyle: stylesheet.list, - baseListContentContainerStyle: stylesheet.listContentContainer, - forwardListContentContainerStyle: stylesheet.forwardListContentContainer, - })} - - {!isNearBottom && ( - - - - - - - - )} - - - ); -}); + return ( + + + + {streamRenderStrategy.render({ + agentId, + segments: renderModel.segments, + boundary, + renderers, + listEmptyComponent, + viewportRef, + routeBottomAnchorRequest, + isAuthoritativeHistoryReady, + onNearBottomChange: setIsNearBottom, + scrollEnabled: streamScrollEnabled, + listStyle: stylesheet.list, + baseListContentContainerStyle: stylesheet.listContentContainer, + forwardListContentContainerStyle: stylesheet.forwardListContentContainer, + })} + + {!isNearBottom && ( + + + + + + + + )} + + + ); + }, +); function WorkingIndicator() { const progress = useSharedValue(0); @@ -711,7 +668,7 @@ function WorkingIndicator() { easing: Easing.linear, }), -1, - false + false, ); return () => { @@ -722,10 +679,7 @@ function WorkingIndicator() { const translateDistance = -2; const dotOneStyle = useAnimatedStyle(() => { - const strength = getWorkingIndicatorDotStrength( - progress.value, - WORKING_INDICATOR_OFFSETS[0] - ); + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]); return { opacity: 0.3 + strength * 0.7, transform: [{ translateY: strength * translateDistance }], @@ -733,10 +687,7 @@ function WorkingIndicator() { }); const dotTwoStyle = useAnimatedStyle(() => { - const strength = getWorkingIndicatorDotStrength( - progress.value, - WORKING_INDICATOR_OFFSETS[1] - ); + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]); return { opacity: 0.3 + strength * 0.7, transform: [{ translateY: strength * translateDistance }], @@ -744,10 +695,7 @@ function WorkingIndicator() { }); const dotThreeStyle = useAnimatedStyle(() => { - const strength = getWorkingIndicatorDotStrength( - progress.value, - WORKING_INDICATOR_OFFSETS[2] - ); + const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]); return { opacity: 0.3 + strength * 0.7, transform: [{ translateY: strength * translateDistance }], @@ -774,14 +722,11 @@ function PermissionRequestCard({ client: DaemonClient | null; }) { const { theme } = useUnistyles(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const { request } = permission; const isPlanRequest = request.kind === "plan"; - const title = isPlanRequest - ? "Plan" - : request.title ?? request.name ?? "Permission Required"; + const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required"); const description = request.description ?? ""; const planMarkdown = useMemo(() => { @@ -789,9 +734,7 @@ function PermissionRequestCard({ return undefined; } const planFromMetadata = - typeof request.metadata?.planText === "string" - ? request.metadata.planText - : undefined; + typeof request.metadata?.planText === "string" ? request.metadata.planText : undefined; if (planFromMetadata) { return planFromMetadata; } @@ -811,7 +754,7 @@ function PermissionRequestCard({ _children: React.ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( {node.content} @@ -822,12 +765,9 @@ function PermissionRequestCard({ children: React.ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( - + {children} ), @@ -836,12 +776,9 @@ function PermissionRequestCard({ _children: React.ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( - + {node.content} ), @@ -850,7 +787,7 @@ function PermissionRequestCard({ _children: React.ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( {node.content} @@ -861,57 +798,31 @@ function PermissionRequestCard({ _children: React.ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( - + {node.content} ), - bullet_list: ( - node: any, - children: React.ReactNode[], - _parent: any, - styles: any - ) => ( + bullet_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => ( {children} ), - ordered_list: ( - node: any, - children: React.ReactNode[], - _parent: any, - styles: any - ) => ( + ordered_list: (node: any, children: React.ReactNode[], _parent: any, styles: any) => ( {children} ), - list_item: ( - node: any, - children: React.ReactNode[], - parent: any, - styles: any - ) => { + list_item: (node: any, children: React.ReactNode[], parent: any, styles: any) => { const { isOrdered, marker } = getMarkdownListMarker(node, parent); - const iconStyle = isOrdered - ? styles.ordered_list_icon - : styles.bullet_list_icon; - const contentStyle = isOrdered - ? styles.ordered_list_content - : styles.bullet_list_content; + const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; + const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; return ( {marker} - - {children} - + {children} ); }, @@ -931,7 +842,7 @@ function PermissionRequestCard({ input.agentId, input.requestId, input.response, - 15000 + 15000, ); }, }); @@ -954,13 +865,10 @@ function PermissionRequestCard({ requestId: permission.request.id, response, }).catch((error) => { - console.error( - "[PermissionRequestCard] Failed to respond to permission:", - error - ); + console.error("[PermissionRequestCard] Failed to respond to permission:", error); }); }, - [permission.agentId, permission.request.id, respondToPermission] + [permission.agentId, permission.request.id, respondToPermission], ); if (request.kind === "question") { @@ -983,19 +891,10 @@ function PermissionRequestCard({ }, ]} > - - {title} - + {title} {description ? ( - + {description} ) : null} @@ -1003,12 +902,7 @@ function PermissionRequestCard({ {planMarkdown ? ( {!isPlanRequest ? ( - + Proposed plan ) : null} @@ -1033,10 +927,7 @@ function PermissionRequestCard({ How would you like to proceed? @@ -1052,9 +943,7 @@ function PermissionRequestCard({ style={({ pressed, hovered = false }) => [ permissionStyles.optionButton, { - backgroundColor: hovered - ? theme.colors.surface2 - : theme.colors.surface1, + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: theme.colors.borderAccent, }, pressed ? permissionStyles.optionButtonPressed : null, @@ -1073,12 +962,7 @@ function PermissionRequestCard({ ) : ( - + Deny @@ -1090,9 +974,7 @@ function PermissionRequestCard({ style={({ pressed, hovered = false }) => [ permissionStyles.optionButton, { - backgroundColor: hovered - ? theme.colors.surface2 - : theme.colors.surface1, + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: theme.colors.borderAccent, }, pressed ? permissionStyles.optionButtonPressed : null, @@ -1108,12 +990,7 @@ function PermissionRequestCard({ ) : ( - + Accept diff --git a/packages/app/src/components/agent-stream-web-virtualization.test.ts b/packages/app/src/components/agent-stream-web-virtualization.test.ts index 8951d2a14..a88e05187 100644 --- a/packages/app/src/components/agent-stream-web-virtualization.test.ts +++ b/packages/app/src/components/agent-stream-web-virtualization.test.ts @@ -62,7 +62,7 @@ describe("findMountedWindowStart", () => { findMountedWindowStart({ items, minMountedCount: 50, - }) + }), ).toBe(0); }); @@ -79,7 +79,7 @@ describe("findMountedWindowStart", () => { findMountedWindowStart({ items, minMountedCount: 50, - }) + }), ).toBe(39); }); }); @@ -136,20 +136,16 @@ describe("web virtualization test overrides", () => { __PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown; __PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown; }; - const previousThreshold = - globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; - const previousMounted = - globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; + const previousThreshold = globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; + const previousMounted = globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; try { delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; expect(getWebPartialVirtualizationThreshold()).toBe( - DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD - ); - expect(getWebMountedRecentStreamItems()).toBe( - DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS + DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD, ); + expect(getWebMountedRecentStreamItems()).toBe(DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS); globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 6; globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = 4; @@ -159,14 +155,12 @@ describe("web virtualization test overrides", () => { if (previousThreshold === undefined) { delete globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; } else { - globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = - previousThreshold; + globalWithOverrides.__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = previousThreshold; } if (previousMounted === undefined) { delete globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS; } else { - globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = - previousMounted; + globalWithOverrides.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS = previousMounted; } } }); diff --git a/packages/app/src/components/agent-stream-web-virtualization.ts b/packages/app/src/components/agent-stream-web-virtualization.ts index ed067c02d..2a5bdb2ce 100644 --- a/packages/app/src/components/agent-stream-web-virtualization.ts +++ b/packages/app/src/components/agent-stream-web-virtualization.ts @@ -18,16 +18,14 @@ function readPositiveIntegerOverride(value: unknown): number | null { export function getWebPartialVirtualizationThreshold(): number { const override = readPositiveIntegerOverride( - (globalThis as BottomAnchorE2ETestGlobals) - .__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD + (globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD, ); return override ?? DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD; } export function getWebMountedRecentStreamItems(): number { const override = readPositiveIntegerOverride( - (globalThis as BottomAnchorE2ETestGlobals) - .__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS + (globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS, ); return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS; } diff --git a/packages/app/src/components/archived-agent-callout.tsx b/packages/app/src/components/archived-agent-callout.tsx index ccb64d5b8..325e0387d 100644 --- a/packages/app/src/components/archived-agent-callout.tsx +++ b/packages/app/src/components/archived-agent-callout.tsx @@ -1,35 +1,35 @@ -import { useState } from 'react' -import { View, Text } from 'react-native' -import { StyleSheet } from 'react-native-unistyles' -import Animated from 'react-native-reanimated' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' -import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' -import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' -import { Button } from '@/components/ui/button' -import type { Theme } from '@/styles/theme' +import { useState } from "react"; +import { View, Text } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import Animated from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { Button } from "@/components/ui/button"; +import type { Theme } from "@/styles/theme"; interface ArchivedAgentCalloutProps { - serverId: string - agentId: string + serverId: string; + agentId: string; } export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) { - const insets = useSafeAreaInsets() - const client = useHostRuntimeClient(serverId) - const isConnected = useHostRuntimeIsConnected(serverId) - const [isUnarchiving, setIsUnarchiving] = useState(false) + const insets = useSafeAreaInsets(); + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const [isUnarchiving, setIsUnarchiving] = useState(false); - const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: 'translate' }) + const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" }); async function handleUnarchive() { - if (!client || !isConnected || isUnarchiving) return - setIsUnarchiving(true) + if (!client || !isConnected || isUnarchiving) return; + setIsUnarchiving(true); try { - await client.refreshAgent(agentId) + await client.refreshAgent(agentId); } catch (error) { - console.error('[ArchivedAgentCallout] Failed to unarchive agent:', error) - setIsUnarchiving(false) + console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error); + setIsUnarchiving(false); } } @@ -53,36 +53,36 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout - ) + ); } const styles = StyleSheet.create(((theme: Theme) => ({ container: { - flexDirection: 'column', - position: 'relative', + flexDirection: "column", + position: "relative", }, inputAreaContainer: { - position: 'relative', + position: "relative", minHeight: FOOTER_HEIGHT, - marginHorizontal: 'auto', - alignItems: 'center', - width: '100%', - overflow: 'visible', + marginHorizontal: "auto", + alignItems: "center", + width: "100%", + overflow: "visible", padding: theme.spacing[4], }, inputAreaContent: { - width: '100%', + width: "100%", maxWidth: MAX_CONTENT_WIDTH, }, callout: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', + flexDirection: "row", + alignItems: "center", + justifyContent: "center", gap: theme.spacing[3], backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius['2xl'], + borderRadius: theme.borderRadius["2xl"], paddingVertical: { xs: theme.spacing[3], md: theme.spacing[4], @@ -96,4 +96,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, -})) as any) as Record +})) as any) as Record; diff --git a/packages/app/src/components/artifact-drawer.tsx b/packages/app/src/components/artifact-drawer.tsx index c0ec709db..d6a43a809 100644 --- a/packages/app/src/components/artifact-drawer.tsx +++ b/packages/app/src/components/artifact-drawer.tsx @@ -1,10 +1,4 @@ -import { - View, - Text, - ScrollView, - Pressable, - Modal, -} from "react-native"; +import { View, Text, ScrollView, Pressable, Modal } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; @@ -185,15 +179,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { - - - {artifact.type.toUpperCase()} - + + {artifact.type.toUpperCase()} × @@ -208,12 +195,8 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { > {artifact.type === "image" ? ( - - Image viewing not yet implemented - - - Base64 image data received - + Image viewing not yet implemented + Base64 image data received ) : ( @@ -244,9 +227,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { Size: - - {content.length.toLocaleString()} characters - + {content.length.toLocaleString()} characters diff --git a/packages/app/src/components/audio-debug-notice.tsx b/packages/app/src/components/audio-debug-notice.tsx index 839974056..5c0d4a48d 100644 --- a/packages/app/src/components/audio-debug-notice.tsx +++ b/packages/app/src/components/audio-debug-notice.tsx @@ -49,7 +49,11 @@ function formatDuration(duration?: number): string | null { return `${seconds.toFixed(seconds >= 10 ? 0 : 1)} s`; } -export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: AudioDebugNoticeProps) { +export function AudioDebugNotice({ + info, + onDismiss, + title = "Dictation Debug", +}: AudioDebugNoticeProps) { const { theme } = useUnistyles(); const [copied, setCopied] = useState(false); @@ -115,11 +119,12 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: {info.debugRecordingPath ? ( - - + + {info.debugRecordingPath} @@ -137,9 +142,7 @@ export function AudioDebugNotice({ info, onDismiss, title = "Dictation Debug" }: )} {stats ? ( - - {stats} - + {stats} ) : null} ); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index beda9ffbd..11bb1efa5 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -1,24 +1,24 @@ -import { useCallback, useMemo, useRef, useState } from 'react' -import { View, Text, Pressable, Platform } from 'react-native' -import { StyleSheet, useUnistyles } from 'react-native-unistyles' -import { ArrowLeft, Check, ChevronDown, ChevronRight } from 'lucide-react-native' -import type { AgentModelDefinition, AgentProvider } from '@server/server/agent/agent-sdk-types' -import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest' -import { Combobox, ComboboxItem, SearchInput } from '@/components/ui/combobox' -import { getProviderIcon } from '@/components/provider-icons' +import { useCallback, useMemo, useRef, useState } from "react"; +import { View, Text, Pressable, Platform } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ArrowLeft, Check, ChevronDown, ChevronRight } from "lucide-react-native"; +import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; +import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; +import { Combobox, ComboboxItem, SearchInput } from "@/components/ui/combobox"; +import { getProviderIcon } from "@/components/provider-icons"; -const INLINE_MODEL_THRESHOLD = 8 +const INLINE_MODEL_THRESHOLD = 8; -type DrillDownView = { provider: string } +type DrillDownView = { provider: string }; interface CombinedModelSelectorProps { - providerDefinitions: AgentProviderDefinition[] - allProviderModels: Map - selectedProvider: string - selectedModel: string - onSelect: (provider: AgentProvider, modelId: string) => void - isLoading: boolean - disabled?: boolean + providerDefinitions: AgentProviderDefinition[]; + allProviderModels: Map; + selectedProvider: string; + selectedModel: string; + onSelect: (provider: AgentProvider, modelId: string) => void; + isLoading: boolean; + disabled?: boolean; } export function CombinedModelSelector({ @@ -30,43 +30,46 @@ export function CombinedModelSelector({ isLoading, disabled = false, }: CombinedModelSelectorProps) { - const { theme } = useUnistyles() - const anchorRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const [view, setView] = useState<'groups' | DrillDownView>('groups') - const [searchQuery, setSearchQuery] = useState('') + const { theme } = useUnistyles(); + const anchorRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [view, setView] = useState<"groups" | DrillDownView>("groups"); + const [searchQuery, setSearchQuery] = useState(""); - const handleOpenChange = useCallback((open: boolean) => { - setIsOpen(open) - if (open) { - const models = allProviderModels.get(selectedProvider) - if (models && models.length > INLINE_MODEL_THRESHOLD) { - setView({ provider: selectedProvider }) + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + if (open) { + const models = allProviderModels.get(selectedProvider); + if (models && models.length > INLINE_MODEL_THRESHOLD) { + setView({ provider: selectedProvider }); + } + } else { + setView("groups"); + setSearchQuery(""); } - } else { - setView('groups') - setSearchQuery('') - } - }, [allProviderModels, selectedProvider]) + }, + [allProviderModels, selectedProvider], + ); const handleSelect = useCallback( (provider: string, modelId: string) => { - onSelect(provider as AgentProvider, modelId) - setIsOpen(false) - setView('groups') - setSearchQuery('') + onSelect(provider as AgentProvider, modelId); + setIsOpen(false); + setView("groups"); + setSearchQuery(""); }, - [onSelect] - ) + [onSelect], + ); - const ProviderIcon = getProviderIcon(selectedProvider) + const ProviderIcon = getProviderIcon(selectedProvider); const selectedModelLabel = useMemo(() => { - const models = allProviderModels.get(selectedProvider) - if (!models) return isLoading ? 'Loading...' : 'Auto' - const model = models.find((m) => m.id === selectedModel) - return model?.label ?? 'Auto' - }, [allProviderModels, selectedProvider, selectedModel, isLoading]) + const models = allProviderModels.get(selectedProvider); + if (!models) return isLoading ? "Loading..." : "Auto"; + const model = models.find((m) => m.id === selectedModel); + return model?.label ?? "Auto"; + }, [allProviderModels, selectedProvider, selectedModel, isLoading]); return ( <> @@ -99,7 +102,7 @@ export function CombinedModelSelector({ desktopPlacement="top-start" title="Select model" > - {view === 'groups' ? ( + {view === "groups" ? ( { - setView({ provider }) - setSearchQuery('') + setView({ provider }); + setSearchQuery(""); }} /> ) : ( @@ -122,14 +125,14 @@ export function CombinedModelSelector({ onSearchChange={setSearchQuery} onSelect={handleSelect} onBack={() => { - setView('groups') - setSearchQuery('') + setView("groups"); + setSearchQuery(""); }} /> )} - ) + ); } function GroupsView({ @@ -140,21 +143,21 @@ function GroupsView({ onSelect, onDrillDown, }: { - providerDefinitions: AgentProviderDefinition[] - allProviderModels: Map - selectedProvider: string - selectedModel: string - onSelect: (provider: string, modelId: string) => void - onDrillDown: (provider: string) => void + providerDefinitions: AgentProviderDefinition[]; + allProviderModels: Map; + selectedProvider: string; + selectedModel: string; + onSelect: (provider: string, modelId: string) => void; + onDrillDown: (provider: string) => void; }) { - const { theme } = useUnistyles() + const { theme } = useUnistyles(); return ( {providerDefinitions.map((def, index) => { - const models = allProviderModels.get(def.id) ?? [] - const isInline = models.length <= INLINE_MODEL_THRESHOLD - const ProvIcon = getProviderIcon(def.id) + const models = allProviderModels.get(def.id) ?? []; + const isInline = models.length <= INLINE_MODEL_THRESHOLD; + const ProvIcon = getProviderIcon(def.id); return ( @@ -193,10 +196,10 @@ function GroupsView({ )} - ) + ); })} - ) + ); } function DrillDownModelView({ @@ -210,27 +213,27 @@ function DrillDownModelView({ onSelect, onBack, }: { - provider: string - providerDefinitions: AgentProviderDefinition[] - models: AgentModelDefinition[] - selectedProvider: string - selectedModel: string - searchQuery: string - onSearchChange: (query: string) => void - onSelect: (provider: string, modelId: string) => void - onBack: () => void + provider: string; + providerDefinitions: AgentProviderDefinition[]; + models: AgentModelDefinition[]; + selectedProvider: string; + selectedModel: string; + searchQuery: string; + onSearchChange: (query: string) => void; + onSelect: (provider: string, modelId: string) => void; + onBack: () => void; }) { - const { theme } = useUnistyles() - const ProvIcon = getProviderIcon(provider) - const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider + const { theme } = useUnistyles(); + const ProvIcon = getProviderIcon(provider); + const providerLabel = providerDefinitions.find((d) => d.id === provider)?.label ?? provider; const filteredModels = useMemo(() => { - if (!searchQuery.trim()) return models - const q = searchQuery.toLowerCase() + if (!searchQuery.trim()) return models; + const q = searchQuery.toLowerCase(); return models.filter( - (m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) - ) - }, [models, searchQuery]) + (m) => m.label.toLowerCase().includes(q) || m.id.toLowerCase().includes(q), + ); + }, [models, searchQuery]); return ( @@ -251,7 +254,7 @@ function DrillDownModelView({ placeholder="Search models..." value={searchQuery} onChangeText={onSearchChange} - autoFocus={Platform.OS === 'web'} + autoFocus={Platform.OS === "web"} /> {filteredModels.map((model) => ( @@ -270,18 +273,18 @@ function DrillDownModelView({ ) : null} - ) + ); } const styles = StyleSheet.create((theme) => ({ trigger: { height: 28, - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'transparent', + flexDirection: "row", + alignItems: "center", + backgroundColor: "transparent", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius['2xl'], + borderRadius: theme.borderRadius["2xl"], }, triggerHovered: { backgroundColor: theme.colors.surface2, @@ -303,8 +306,8 @@ const styles = StyleSheet.create((theme) => ({ marginVertical: theme.spacing[1], }, sectionHeading: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], @@ -315,8 +318,8 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, drillDownRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -334,8 +337,8 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, drillDownTrailing: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], }, drillDownCount: { @@ -343,8 +346,8 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, backButton: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -363,10 +366,10 @@ const styles = StyleSheet.create((theme) => ({ }, emptyState: { paddingVertical: theme.spacing[4], - alignItems: 'center', + alignItems: "center", }, emptyStateText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, -})) +})); diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 0ba00a273..4ff7af037 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -1,12 +1,4 @@ -import { - Modal, - Pressable, - ScrollView, - Text, - TextInput, - View, - Platform, -} from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; import { memo, useEffect, useRef, type ReactNode } from "react"; import { Plus, Settings } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -54,16 +46,8 @@ const CommandCenterRow = memo(function CommandCenterRow({ export function CommandCenter() { const { theme } = useUnistyles(); - const { - open, - inputRef, - query, - setQuery, - activeIndex, - items, - handleClose, - handleSelectItem, - } = useCommandCenter(); + const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } = + useCommandCenter(); const rowRefs = useRef>(new Map()); const resultsRef = useRef(null); @@ -77,11 +61,13 @@ export function CommandCenter() { return; } const scrollNode = - (resultsRef.current as - | (ScrollView & { - getScrollableNode?: () => HTMLElement | null; - }) - | null)?.getScrollableNode?.() ?? null; + ( + resultsRef.current as + | (ScrollView & { + getScrollableNode?: () => HTMLElement | null; + }) + | null + )?.getScrollableNode?.() ?? null; const rowEl = row as unknown as HTMLElement; if (!scrollNode) { @@ -110,18 +96,16 @@ export function CommandCenter() { const agentItems = items.filter((item) => item.kind === "agent"); return ( - + + ) : action.icon === "settings" ? ( - - + + {isConnected ? "Connected" : "Disconnected"} diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx index 37f27b032..525bc571e 100644 --- a/packages/app/src/components/dictation-controls.tsx +++ b/packages/app/src/components/dictation-controls.tsx @@ -24,9 +24,7 @@ interface DictationControlsProps { function formatDuration(seconds: number): string { const mins = Math.floor(seconds / 60); const secs = seconds % 60; - return `${mins.toString().padStart(2, "0")}:${secs - .toString() - .padStart(2, "0")}`; + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; } export function DictationControls({ @@ -66,12 +64,7 @@ export function DictationControls({ return ( - + {formatDuration(duration)} @@ -152,12 +145,7 @@ export function DictationOverlay({ } return ( - + - + {formatDuration(duration)} @@ -205,26 +188,16 @@ export function DictationOverlay({ {actionsDisabled ? ( - + ) : isFailed ? ( - + ) : ( <> @@ -232,10 +205,7 @@ export function DictationOverlay({ onPress={onAccept} accessibilityRole="button" accessibilityLabel="Insert transcription" - style={[ - overlayStyles.actionButton, - { backgroundColor: "rgba(255, 255, 255, 0.25)" }, - ]} + style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]} > - + )} diff --git a/packages/app/src/components/dictation-status-notice.tsx b/packages/app/src/components/dictation-status-notice.tsx index 96d31d4a9..1ae655dcc 100644 --- a/packages/app/src/components/dictation-status-notice.tsx +++ b/packages/app/src/components/dictation-status-notice.tsx @@ -1,6 +1,14 @@ import { View, Text, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { AlertTriangle, CheckCircle2, Info, RefreshCcw, RotateCcw, WifiOff, X } from "lucide-react-native"; +import { + AlertTriangle, + CheckCircle2, + Info, + RefreshCcw, + RotateCcw, + WifiOff, + X, +} from "lucide-react-native"; export type DictationToastVariant = "info" | "success" | "warning" | "error"; @@ -53,7 +61,8 @@ export function DictationStatusNotice({ })(); const foregroundColor = variant === "info" ? theme.colors.foreground : theme.colors.palette.white; - const secondaryColor = variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white; + const secondaryColor = + variant === "info" ? theme.colors.foregroundMuted : theme.colors.palette.white; return ( - {meta ? ( - {meta} - ) : ( - - )} + {meta ? {meta} : } {actionLabel && onAction ? ( {segment.text} diff --git a/packages/app/src/components/download-toast.tsx b/packages/app/src/components/download-toast.tsx index 5bbd6515c..7dc5f97d0 100644 --- a/packages/app/src/components/download-toast.tsx +++ b/packages/app/src/components/download-toast.tsx @@ -41,7 +41,10 @@ export function DownloadToast() { } return ( - + {activeDownload.status === "downloading" ? ( @@ -61,7 +64,7 @@ export function DownloadToast() { : "Starting..." : activeDownload.status === "complete" ? "Download complete" - : activeDownload.message ?? "Download failed"} + : (activeDownload.message ?? "Download failed")} {activeDownload.status === "downloading" && activeDownload.progress && ( diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index f7b654060..d88e0eb12 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -5,10 +5,7 @@ import DraggableFlatList, { type RenderItemParams, } from "react-native-draggable-flatlist"; import { useUnistyles } from "react-native-unistyles"; -import type { - DraggableListProps, - DraggableRenderItemInfo, -} from "./draggable-list.types"; +import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types"; export type { DraggableListProps, DraggableRenderItemInfo }; @@ -53,7 +50,7 @@ export function DraggableList({ }; return renderItem(info); }, - [renderItem] + [renderItem], ); const handleDragEnd = useCallback( @@ -61,7 +58,7 @@ export function DraggableList({ setIsDragging(false); onDragEnd(newData); }, - [onDragEnd] + [onDragEnd], ); const handleDragBegin = useCallback(() => { @@ -74,12 +71,11 @@ export function DraggableList({ }, []); const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing)); - const resolvedContainerStyle = - containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined); + const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined); const shouldShowRefreshControl = showRefreshControl && !nestable; - const ListComponent: typeof DraggableFlatList = (nestable - ? (NestableDraggableFlatList as any) - : DraggableFlatList) as any; + const ListComponent: typeof DraggableFlatList = ( + nestable ? (NestableDraggableFlatList as any) : DraggableFlatList + ) as any; return ( ({ // the "ghost" stretches. Keep the dragged item's size stable by zeroing // out the dnd-kit scaling component. const baseTransform = CSS.Transform.toString( - transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform + transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform, ); const scaleTransform = isDragging ? "scale(1.02)" : ""; const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" "); @@ -100,9 +94,7 @@ function SortableItem({ ? { attributes: attributes as unknown as Record, listeners: listeners as unknown as Record, - setActivatorNodeRef: setActivatorNodeRef as unknown as ( - node: unknown - ) => void, + setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void, } : undefined, }; @@ -152,7 +144,7 @@ export function DraggableList({ }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, - }) + }), ); const handleDragStart = useCallback( @@ -161,7 +153,7 @@ export function DraggableList({ setActiveId(String(event.active.id)); onDragBegin?.(); }, - [data, onDragBegin] + [data, onDragBegin], ); const handleDragEnd = useCallback( @@ -172,12 +164,8 @@ export function DraggableList({ setDragItems(null); if (over && active.id !== over.id) { - const oldIndex = items.findIndex( - (item, i) => keyExtractor(item, i) === active.id - ); - const newIndex = items.findIndex( - (item, i) => keyExtractor(item, i) === over.id - ); + const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id); + const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id); if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { const newItems = arrayMove(items, oldIndex, newIndex); @@ -185,7 +173,7 @@ export function DraggableList({ } } }, - [items, keyExtractor, onDragEnd] + [items, keyExtractor, onDragEnd], ); const ids = items.map((item, index) => keyExtractor(item, index)); @@ -204,9 +192,7 @@ export function DraggableList({ testID={testID} style={style} contentContainerStyle={contentContainerStyle} - showsVerticalScrollIndicator={ - showCustomScrollbar ? false : showsVerticalScrollIndicator - } + showsVerticalScrollIndicator={showCustomScrollbar ? false : showsVerticalScrollIndicator} onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined} onContentSizeChange={ showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined diff --git a/packages/app/src/components/empty-state.tsx b/packages/app/src/components/empty-state.tsx index 1dff80b00..fdf34901b 100644 --- a/packages/app/src/components/empty-state.tsx +++ b/packages/app/src/components/empty-state.tsx @@ -21,10 +21,7 @@ export function EmptyState({ onCreateAgent, onImportAgent }: EmptyStateProps) { New agent {hasImportCta ? ( - + Import agent diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 8ae39c217..1499bfe03 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useIsFocused } from "@react-navigation/native"; -import Animated, { - useAnimatedStyle, - useSharedValue, - runOnJS, -} from "react-native-reanimated"; +import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; @@ -23,8 +19,7 @@ import { FileExplorerPane } from "./file-explorer-pane"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; const MIN_CHAT_WIDTH = 400; -function logExplorerSidebar(_event: string, _details: Record): void { -} +function logExplorerSidebar(_event: string, _details: Record): void {} interface ExplorerSidebarProps { serverId: string; @@ -44,8 +39,7 @@ export function ExplorerSidebar({ const { theme } = useUnistyles(); const isScreenFocused = useIsFocused(); const insets = useSafeAreaInsets(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const closeToAgent = usePanelStore((state) => state.closeToAgent); @@ -68,7 +62,7 @@ export function ExplorerSidebar({ } const maxWidth = Math.max( MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH) + Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), ); if (explorerWidth > maxWidth) { setExplorerWidth(maxWidth); @@ -102,7 +96,7 @@ export function ExplorerSidebar({ }); closeToAgent(); }, - [closeToAgent, desktopFileExplorerOpen, isOpen, mobileView] + [closeToAgent, desktopFileExplorerOpen, isOpen, mobileView], ); const enableSidebarCloseGesture = isMobile && isOpen; @@ -111,7 +105,7 @@ export function ExplorerSidebar({ (tab: ExplorerTab) => { setExplorerTabForCheckout({ serverId, cwd: workspaceRoot, isGit, tab }); }, - [isGit, serverId, setExplorerTabForCheckout, workspaceRoot] + [isGit, serverId, setExplorerTabForCheckout, workspaceRoot], ); // Swipe gesture to close (swipe right on mobile) @@ -170,8 +164,7 @@ export function ExplorerSidebar({ }) .onEnd((event) => { isGesturing.value = false; - const shouldClose = - event.translationX > windowWidth / 3 || event.velocityX > 500; + const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500; runOnJS(logExplorerSidebar)("closeGestureEnd", { translationX: event.translationX, velocityX: event.velocityX, @@ -200,7 +193,7 @@ export function ExplorerSidebar({ closeGestureRef, closeTouchStartX, closeTouchStartY, - ] + ], ); // Desktop resize gesture (drag left edge) @@ -218,18 +211,15 @@ export function ExplorerSidebar({ const newWidth = startWidthRef.current - event.translationX; const maxWidth = Math.max( MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH) - ); - const clampedWidth = Math.max( - MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(maxWidth, newWidth) + Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH), ); + const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth)); resizeWidth.value = clampedWidth; }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); }), - [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth] + [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth], ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ @@ -299,16 +289,11 @@ export function ExplorerSidebar({ } return ( - + {/* Resize handle - absolutely positioned over left border */} @@ -351,8 +336,7 @@ function SidebarContent({ onOpenFile, }: SidebarContentProps) { const { theme } = useUnistyles(); - const resolvedTab: ExplorerTab = - !isGit && activeTab === "changes" ? "files" : activeTab; + const resolvedTab: ExplorerTab = !isGit && activeTab === "changes" ? "files" : activeTab; return ( @@ -365,12 +349,7 @@ function SidebarContent({ style={[styles.tab, resolvedTab === "changes" && styles.tabActive]} onPress={() => onTabPress("changes")} > - + Changes @@ -380,12 +359,7 @@ function SidebarContent({ style={[styles.tab, resolvedTab === "files" && styles.tabActive]} onPress={() => onTabPress("files")} > - + Files diff --git a/packages/app/src/components/file-drop-zone.tsx b/packages/app/src/components/file-drop-zone.tsx index 13dc3b9c3..a50940cc2 100644 --- a/packages/app/src/components/file-drop-zone.tsx +++ b/packages/app/src/components/file-drop-zone.tsx @@ -1,10 +1,6 @@ import { View, Text, Platform } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import Animated, { - useAnimatedStyle, - withTiming, - useSharedValue, -} from "react-native-reanimated"; +import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated"; import { useEffect } from "react"; import { Upload } from "lucide-react-native"; import { useFileDropZone } from "@/hooks/use-file-drop-zone"; @@ -18,11 +14,7 @@ interface FileDropZoneProps { const IS_WEB = Platform.OS === "web"; -export function FileDropZone({ - children, - onFilesDropped, - disabled = false, -}: FileDropZoneProps) { +export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) { const { theme } = useUnistyles(); const { isDragging, containerRef } = useFileDropZone({ onFilesDropped, diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 14d15282f..a030dc8b0 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -38,10 +38,7 @@ import { RotateCw, X, } from "lucide-react-native"; -import type { - AgentFileExplorerState, - ExplorerEntry, -} from "@/stores/session-store"; +import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store"; import { useHosts } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useDownloadStore } from "@/stores/download-store"; @@ -54,10 +51,7 @@ import { } from "@/components/ui/dropdown-menu"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; -import { - usePanelStore, - type SortOption, -} from "@/stores/panel-store"; +import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths"; import { @@ -102,36 +96,32 @@ export function FileExplorerPane({ onOpenFile, }: FileExplorerPaneProps) { const { theme } = useUnistyles(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const daemons = useHosts(); const daemonProfile = useMemo( () => daemons.find((daemon) => daemon.serverId === serverId), - [daemons, serverId] - ); - const normalizedWorkspaceRoot = useMemo( - () => workspaceRoot.trim(), - [workspaceRoot] + [daemons, serverId], ); + const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const workspaceStateKey = useMemo( () => buildWorkspaceExplorerStateKey({ workspaceId, workspaceRoot: normalizedWorkspaceRoot, }), - [normalizedWorkspaceRoot, workspaceId] + [normalizedWorkspaceRoot, workspaceId], ); const workspaceScopeId = useMemo( () => workspaceId?.trim() || normalizedWorkspaceRoot, - [normalizedWorkspaceRoot, workspaceId] + [normalizedWorkspaceRoot, workspaceId], ); const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot); const explorerState = useSessionStore((state) => workspaceStateKey && state.sessions[serverId] ? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey) - : undefined + : undefined, ); const { @@ -156,9 +146,9 @@ export function FileExplorerPane({ const isDirectoryLoading = useCallback( (path: string) => Boolean( - isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path + isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path, ), - [isExplorerLoading, pendingRequest?.mode, pendingRequest?.path] + [isExplorerLoading, pendingRequest?.mode, pendingRequest?.path], ); const [expandedPaths, setExpandedPaths] = useState>(() => new Set(["."])); @@ -235,7 +225,7 @@ export function FileExplorerPane({ }); } }, - [directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing] + [directories, expandedPaths, hasWorkspaceScope, requestDirectoryListing], ); const handleOpenFile = useCallback( @@ -246,7 +236,7 @@ export function FileExplorerPane({ selectExplorerEntry(entry.path); onOpenFile?.(entry.path); }, - [hasWorkspaceScope, onOpenFile, selectExplorerEntry] + [hasWorkspaceScope, onOpenFile, selectExplorerEntry], ); const handleEntryPress = useCallback( @@ -257,7 +247,7 @@ export function FileExplorerPane({ } handleOpenFile(entry); }, - [handleOpenFile, handleToggleDirectory] + [handleOpenFile, handleToggleDirectory], ); const handleCopyPath = useCallback( @@ -266,10 +256,10 @@ export function FileExplorerPane({ buildAbsoluteExplorerPath({ workspaceRoot: normalizedWorkspaceRoot, entryPath: path, - }) + }), ); }, - [normalizedWorkspaceRoot] + [normalizedWorkspaceRoot], ); const startDownload = useDownloadStore((state) => state.startDownload); @@ -288,13 +278,7 @@ export function FileExplorerPane({ requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), }); }, - [ - daemonProfile, - requestFileDownloadToken, - serverId, - startDownload, - workspaceScopeId, - ] + [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId], ); const handleSortCycle = useCallback(() => { @@ -320,7 +304,7 @@ export function FileExplorerPane({ requestDirectoryListing(path, { recordHistory: false, setCurrentPath: false, - }) + }), ), ]); return null; @@ -342,7 +326,7 @@ export function FileExplorerPane({ easing: Easing.linear, }), -1, - false + false, ); return; } @@ -366,7 +350,7 @@ export function FileExplorerPane({ if (finished) { refreshIconRotation.value = 0; } - } + }, ); }, [isRefreshFetching, refreshIconRotation]); @@ -394,10 +378,7 @@ export function FileExplorerPane({ !directories.has(".") && Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === "."); const showBackFromError = Boolean(error && selectedEntryPath); - const errorRecoveryPath = useMemo( - () => getErrorRecoveryPath(explorerState), - [explorerState] - ); + const errorRecoveryPath = useMemo(() => getErrorRecoveryPath(explorerState), [explorerState]); const renderTreeRow = useCallback( ({ item }: ListRenderItemInfo) => { @@ -495,7 +476,7 @@ export function FileExplorerPane({ selectedEntryPath, theme.colors, theme.spacing, - ] + ], ); const handleBackFromError = useCallback(() => { @@ -515,7 +496,7 @@ export function FileExplorerPane({ treeScrollbarMetrics.onScroll(event); } }, - [showDesktopWebScrollbar, treeScrollbarMetrics] + [showDesktopWebScrollbar, treeScrollbarMetrics], ); const handleTreeListLayout = useCallback( @@ -524,7 +505,7 @@ export function FileExplorerPane({ treeScrollbarMetrics.onLayout(event); } }, - [showDesktopWebScrollbar, treeScrollbarMetrics] + [showDesktopWebScrollbar, treeScrollbarMetrics], ); if (!hasWorkspaceScope) { @@ -536,9 +517,7 @@ export function FileExplorerPane({ } return ( - + {error ? ( {error} @@ -632,16 +611,7 @@ export function FileExplorerPane({ type EntryDisplayKind = "directory" | "image" | "text" | "other"; -const IMAGE_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "svg", - "webp", - "ico", -]); +const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico"]); const TEXT_EXTENSIONS = new Set([ "txt", @@ -684,7 +654,7 @@ const TEXT_EXTENSIONS = new Set([ function renderEntryIcon( kind: EntryDisplayKind, - colors: { foreground: string; primary: string; directoryOpen?: boolean } + colors: { foreground: string; primary: string; directoryOpen?: boolean }, ) { const color = colors.foreground; switch (kind) { @@ -783,7 +753,7 @@ function buildTreeRows({ sortOption, path: entry.path, depth: depth + 1, - }) + }), ); } } diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index 99466f833..d16196a02 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useMemo, useRef } from 'react' -import { useQuery } from '@tanstack/react-query' +import React, { useCallback, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { ActivityIndicator, Image as RNImage, @@ -10,54 +10,54 @@ import { type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, -} from 'react-native' -import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' -import { Fonts } from '@/constants/theme' -import { useSessionStore, type ExplorerFile } from '@/stores/session-store' +} from "react-native"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; +import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, -} from '@/components/web-desktop-scrollbar' +} from "@/components/web-desktop-scrollbar"; import { highlightCode, darkHighlightColors, lightHighlightColors, type HighlightToken, type HighlightStyle, -} from '@getpaseo/highlight' +} from "@getpaseo/highlight"; interface CodeLineProps { - tokens: HighlightToken[] - lineNumber: number - gutterWidth: number - colorMap: Record - baseColor: string + tokens: HighlightToken[]; + lineNumber: number; + gutterWidth: number; + colorMap: Record; + baseColor: string; } interface FilePreviewBodyProps { - preview: ExplorerFile | null - isLoading: boolean - showDesktopWebScrollbar: boolean - isMobile: boolean - filePath: string + preview: ExplorerFile | null; + isLoading: boolean; + showDesktopWebScrollbar: boolean; + isMobile: boolean; + filePath: string; } function trimNonEmpty(value: string | null | undefined): string | null { - if (typeof value !== 'string') { - return null + if (typeof value !== "string") { + return null; } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; } function formatFileSize({ size }: { size: number }): string { if (size < 1024) { - return `${size} B` + return `${size} B`; } if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(1)} KB` + return `${(size / 1024).toFixed(1)} KB`; } - return `${(size / (1024 * 1024)).toFixed(1)} MB` + return `${(size / (1024 * 1024)).toFixed(1)} MB`; } const CodeLine = React.memo(function CodeLine({ @@ -76,22 +76,22 @@ const CodeLine = React.memo(function CodeLine({ {tokens.map((token, index) => ( {token.text} ))} - ) -}) + ); +}); const codeLineStyles = StyleSheet.create((theme) => ({ line: { - flexDirection: 'row', + flexDirection: "row", }, gutter: { - alignItems: 'flex-end', + alignItems: "flex-end", paddingRight: theme.spacing[3], flexShrink: 0, }, @@ -107,7 +107,7 @@ const codeLineStyles = StyleSheet.create((theme) => ({ lineHeight: theme.fontSize.sm * 1.45, flex: 1, }, -})) +})); function FilePreviewBody({ preview, @@ -116,46 +116,46 @@ function FilePreviewBody({ isMobile, filePath, }: FilePreviewBodyProps) { - const { theme } = useUnistyles() - const isDark = theme.colors.surface0 === '#18181c' - const colorMap = isDark ? darkHighlightColors : lightHighlightColors - const baseColor = isDark ? '#c9d1d9' : '#24292f' + const { theme } = useUnistyles(); + const isDark = theme.colors.surface0 === "#18181c"; + const colorMap = isDark ? darkHighlightColors : lightHighlightColors; + const baseColor = isDark ? "#c9d1d9" : "#24292f"; - const enablePreviewDesktopScrollbar = showDesktopWebScrollbar - const previewScrollRef = useRef(null) - const previewScrollbarMetrics = useWebDesktopScrollbarMetrics() + const enablePreviewDesktopScrollbar = showDesktopWebScrollbar; + const previewScrollRef = useRef(null); + const previewScrollbarMetrics = useWebDesktopScrollbarMetrics(); const highlightedLines = useMemo(() => { - if (!preview || preview.kind !== 'text') { - return null + if (!preview || preview.kind !== "text") { + return null; } - return highlightCode(preview.content ?? '', filePath) - }, [preview?.kind, preview?.content, filePath]) + return highlightCode(preview.content ?? "", filePath); + }, [preview?.kind, preview?.content, filePath]); const gutterWidth = useMemo(() => { - if (!highlightedLines) return 0 - const digits = String(highlightedLines.length).length - return Math.max(digits * 8 + 12, 28) - }, [highlightedLines]) + if (!highlightedLines) return 0; + const digits = String(highlightedLines.length).length; + return Math.max(digits * 8 + 12, 28); + }, [highlightedLines]); const handlePreviewScroll = useCallback( (event: NativeSyntheticEvent) => { if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onScroll(event) + previewScrollbarMetrics.onScroll(event); } }, - [enablePreviewDesktopScrollbar, previewScrollbarMetrics] - ) + [enablePreviewDesktopScrollbar, previewScrollbarMetrics], + ); const handlePreviewLayout = useCallback( (event: LayoutChangeEvent) => { if (enablePreviewDesktopScrollbar) { - previewScrollbarMetrics.onLayout(event) + previewScrollbarMetrics.onLayout(event); } }, - [enablePreviewDesktopScrollbar, previewScrollbarMetrics] - ) + [enablePreviewDesktopScrollbar, previewScrollbarMetrics], + ); if (isLoading && !preview) { return ( @@ -163,7 +163,7 @@ function FilePreviewBody({ Loading file… - ) + ); } if (!preview) { @@ -171,11 +171,11 @@ function FilePreviewBody({ No preview available - ) + ); } - if (preview.kind === 'text') { - const lines = highlightedLines ?? [[{ text: preview.content ?? '', style: null }]] + if (preview.kind === "text") { + const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]]; const codeLines = ( {lines.map((tokens, index) => ( @@ -189,7 +189,7 @@ function FilePreviewBody({ /> ))} - ) + ); return ( @@ -221,14 +221,14 @@ function FilePreviewBody({ enabled={enablePreviewDesktopScrollbar} metrics={previewScrollbarMetrics} onScrollToOffset={(nextOffset) => { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }) + previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); }} /> - ) + ); } - if (preview.kind === 'image' && preview.content) { + if (preview.kind === "image" && preview.content) { return ( { - previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }) + previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false }); }} /> - ) + ); } return ( @@ -267,7 +267,7 @@ function FilePreviewBody({ Binary preview unavailable {formatFileSize({ size: preview.size })} - ) + ); } export function FilePane({ @@ -275,33 +275,33 @@ export function FilePane({ workspaceRoot, filePath, }: { - serverId: string - workspaceRoot: string - filePath: string + serverId: string; + workspaceRoot: string; + filePath: string; }) { - const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null) - const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]) - const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]) + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); + const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]); const query = useQuery({ - queryKey: ['workspaceFile', serverId, normalizedWorkspaceRoot, normalizedFilePath], + queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath], enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath), queryFn: async () => { if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) { - return { file: null as ExplorerFile | null, error: 'Host is not connected' } + return { file: null as ExplorerFile | null, error: "Host is not connected" }; } const payload = await client.exploreFileSystem( normalizedWorkspaceRoot, normalizedFilePath, - 'file' - ) - return { file: payload.file ?? null, error: payload.error ?? null } + "file", + ); + return { file: payload.file ?? null, error: payload.error ?? null }; }, staleTime: 5_000, - }) + }); return ( @@ -319,7 +319,7 @@ export function FilePane({ filePath={filePath} /> - ) + ); } const styles = StyleSheet.create((theme) => ({ @@ -330,8 +330,8 @@ const styles = StyleSheet.create((theme) => ({ }, centerState: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", padding: theme.spacing[4], }, loadingText: { @@ -342,12 +342,12 @@ const styles = StyleSheet.create((theme) => ({ errorText: { color: theme.colors.destructive, fontSize: theme.fontSize.sm, - textAlign: 'center', + textAlign: "center", }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - textAlign: 'center', + textAlign: "center", }, binaryMetaText: { marginTop: theme.spacing[2], @@ -368,11 +368,11 @@ const styles = StyleSheet.create((theme) => ({ previewImageScrollContent: { flexGrow: 1, padding: theme.spacing[4], - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, previewImage: { - width: '100%', + width: "100%", height: 420, }, -})) +})); diff --git a/packages/app/src/components/git-actions-policy.test.ts b/packages/app/src/components/git-actions-policy.test.ts index 5864113ce..e1dc936f5 100644 --- a/packages/app/src/components/git-actions-policy.test.ts +++ b/packages/app/src/components/git-actions-policy.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy"; -function createInput( - overrides: Partial = {} -): BuildGitActionsInput { +function createInput(overrides: Partial = {}): BuildGitActionsInput { return { isGit: true, githubFeaturesEnabled: true, @@ -68,7 +66,7 @@ describe("git-actions-policy", () => { aheadCount: 3, aheadOfOrigin: 2, shipDefault: "pr", - }) + }), ); expect(noPrActions.primary).toBeNull(); @@ -117,18 +115,20 @@ describe("git-actions-policy", () => { hasRemote: true, hasPullRequest: true, pullRequestUrl: "https://example.com/pr/456", - }) + }), ); expect(actions.primary?.id).toBe("pr"); - expect(actions.secondary.some((action) => action.id === "pr" && action.label === "View PR")).toBe(true); + expect( + actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"), + ).toBe(true); }); it("disables sync on the base branch when already up to date", () => { const actions = buildGitActions( createInput({ hasRemote: true, - }) + }), ); const syncAction = actions.secondary.find((action) => action.id === "merge-from-base"); diff --git a/packages/app/src/components/git-actions-policy.ts b/packages/app/src/components/git-actions-policy.ts index f9ac556d8..768cc3a90 100644 --- a/packages/app/src/components/git-actions-policy.ts +++ b/packages/app/src/components/git-actions-policy.ts @@ -54,12 +54,7 @@ export interface BuildGitActionsInput { runtime: Record; } -const SECONDARY_ACTION_IDS: GitActionId[] = [ - "merge-branch", - "pr", - "merge-from-base", - "push", -]; +const SECONDARY_ACTION_IDS: GitActionId[] = ["merge-branch", "pr", "merge-from-base", "push"]; export function buildGitActions(input: BuildGitActionsInput): GitActions { if (!input.isGit) { @@ -134,7 +129,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions { }); const primaryActionId = getPrimaryActionId(input); - const primary = primaryActionId ? allActions.get(primaryActionId) ?? null : null; + const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null; const secondary = SECONDARY_ACTION_IDS.map((id) => allActions.get(id)!); if (input.isPaseoOwnedWorktree) { secondary.push(allActions.get("archive-worktree")!); @@ -189,10 +184,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction { label: "Create PR", pendingLabel: "Creating PR...", successLabel: "PR Created", - disabled: - input.runtime.pr.disabled || - !input.githubFeaturesEnabled || - input.aheadCount === 0, + disabled: input.runtime.pr.disabled || !input.githubFeaturesEnabled || input.aheadCount === 0, status: input.runtime.pr.status, description: getCreatePrDescription(input), icon: input.runtime.pr.icon, diff --git a/packages/app/src/components/git-actions-split-button.tsx b/packages/app/src/components/git-actions-split-button.tsx index c746d70ba..718d6080b 100644 --- a/packages/app/src/components/git-actions-split-button.tsx +++ b/packages/app/src/components/git-actions-split-button.tsx @@ -49,7 +49,9 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps ) : ( {gitActions.primary.icon} - {getActionDisplayLabel(gitActions.primary)} + + {getActionDisplayLabel(gitActions.primary)} + )} diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index c1ccab82e..601c526f5 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -7,8 +7,8 @@ import { useRef, memo, type ReactElement, -} from 'react' -import { useRouter } from 'expo-router' +} from "react"; +import { useRouter } from "expo-router"; import { View, Text, @@ -19,10 +19,10 @@ import { type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, -} from 'react-native' -import { ScrollView, type ScrollView as ScrollViewType } from 'react-native-gesture-handler' -import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' -import AsyncStorage from '@react-native-async-storage/async-storage' +} from "react-native"; +import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import AsyncStorage from "@react-native-async-storage/async-storage"; import { Archive, ChevronDown, @@ -34,69 +34,69 @@ import { RefreshCcw, Upload, WrapText, -} from 'lucide-react-native' -import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' +} from "lucide-react-native"; +import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; import { useCheckoutDiffQuery, type ParsedDiffFile, type DiffLine, type HighlightToken, -} from '@/hooks/use-checkout-diff-query' -import { useCheckoutStatusQuery } from '@/hooks/use-checkout-status-query' -import { useCheckoutPrStatusQuery } from '@/hooks/use-checkout-pr-status-query' -import { useHorizontalScrollOptional } from '@/contexts/horizontal-scroll-context' -import { useExplorerSidebarAnimation } from '@/contexts/explorer-sidebar-animation-context' +} from "@/hooks/use-checkout-diff-query"; +import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; +import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; +import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; +import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; import { darkHighlightColors, lightHighlightColors, type HighlightStyle as HighlightStyleKey, -} from '@getpaseo/highlight' -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from '@/constants/layout' -import { Fonts } from '@/constants/theme' -import { shouldAnchorHeaderBeforeCollapse } from '@/utils/git-diff-scroll' +} from "@getpaseo/highlight"; +import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { Fonts } from "@/constants/theme"; +import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' -import { GitHubIcon } from '@/components/icons/github-icon' -import { buildGitActions, type GitActions } from '@/components/git-actions-policy' +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; +import { GitHubIcon } from "@/components/icons/github-icon"; +import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, -} from '@/components/web-desktop-scrollbar' -import { buildNewAgentRoute, resolveNewAgentWorkingDir } from '@/utils/new-agent-routing' -import { openExternalUrl } from '@/utils/open-external-url' -import { GitActionsSplitButton } from '@/components/git-actions-split-button' +} from "@/components/web-desktop-scrollbar"; +import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { GitActionsSplitButton } from "@/components/git-actions-split-button"; -export type { GitActionId, GitAction, GitActions } from '@/components/git-actions-policy' +export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy"; function openURLInNewTab(url: string): void { - void openExternalUrl(url) + void openExternalUrl(url); } -type HighlightStyle = NonNullable +type HighlightStyle = NonNullable; interface HighlightedTextProps { - tokens: HighlightToken[] - baseStyle: HighlightStyle | null - lineType: 'add' | 'remove' | 'context' | 'header' + tokens: HighlightToken[]; + baseStyle: HighlightStyle | null; + lineType: "add" | "remove" | "context" | "header"; } function HighlightedText({ tokens, lineType }: HighlightedTextProps) { - const { theme } = useUnistyles() - const isDark = theme.colors.surface0 === '#18181c' + const { theme } = useUnistyles(); + const isDark = theme.colors.surface0 === "#18181c"; // Get color for a highlight style const getTokenColor = (style: HighlightStyle | null): string => { - const baseColor = isDark ? '#c9d1d9' : '#24292f' - if (!style) return baseColor - const colors = isDark ? darkHighlightColors : lightHighlightColors - return colors[style as HighlightStyleKey] ?? baseColor - } + const baseColor = isDark ? "#c9d1d9" : "#24292f"; + if (!style) return baseColor; + const colors = isDark ? darkHighlightColors : lightHighlightColors; + return colors[style as HighlightStyleKey] ?? baseColor; + }; return ( @@ -106,15 +106,15 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) { ))} - ) + ); } interface DiffFileSectionProps { - file: ParsedDiffFile - isExpanded: boolean - onToggle: (path: string) => void - onHeaderHeightChange?: (path: string, height: number) => void - testID?: string + file: ParsedDiffFile; + isExpanded: boolean; + onToggle: (path: string) => void; + onHeaderHeightChange?: (path: string, height: number) => void; + testID?: string; } function DiffLineView({ @@ -122,48 +122,48 @@ function DiffLineView({ lineNumber, gutterWidth, }: { - line: DiffLine - lineNumber: number | null - gutterWidth: number + line: DiffLine; + lineNumber: number | null; + gutterWidth: number; }) { return ( - {lineNumber != null ? String(lineNumber) : ''} + {lineNumber != null ? String(lineNumber) : ""} - {line.tokens && line.type !== 'header' ? ( + {line.tokens && line.type !== "header" ? ( ) : ( - {line.content || ' '} + {line.content || " "} )} - ) + ); } const DiffFileHeader = memo(function DiffFileHeader({ @@ -173,21 +173,21 @@ const DiffFileHeader = memo(function DiffFileHeader({ onHeaderHeightChange, testID, }: DiffFileSectionProps) { - const layoutYRef = useRef(null) - const pressHandledRef = useRef(false) - const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null) + const layoutYRef = useRef(null); + const pressHandledRef = useRef(false); + const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); const toggleExpanded = useCallback(() => { - pressHandledRef.current = true - onToggle(file.path) - }, [file.path, onToggle]) + pressHandledRef.current = true; + onToggle(file.path); + }, [file.path, onToggle]); return ( { - layoutYRef.current = event.nativeEvent.layout.y - onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height) + layoutYRef.current = event.nativeEvent.layout.y; + onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height); }} testID={testID} > @@ -197,37 +197,37 @@ const DiffFileHeader = memo(function DiffFileHeader({ // Android: prevent parent pan/scroll gestures from canceling the tap release. cancelable={false} onPressIn={(event) => { - pressHandledRef.current = false + pressHandledRef.current = false; pressInRef.current = { ts: Date.now(), pageX: event.nativeEvent.pageX, pageY: event.nativeEvent.pageY, - } + }; }} onPressOut={(event) => { if ( - Platform.OS !== 'web' && + Platform.OS !== "web" && !pressHandledRef.current && layoutYRef.current === 0 && pressInRef.current ) { - const durationMs = Date.now() - pressInRef.current.ts - const dx = event.nativeEvent.pageX - pressInRef.current.pageX - const dy = event.nativeEvent.pageY - pressInRef.current.pageY - const distance = Math.hypot(dx, dy) + const durationMs = Date.now() - pressInRef.current.ts; + const dx = event.nativeEvent.pageX - pressInRef.current.pageX; + const dy = event.nativeEvent.pageY - pressInRef.current.pageY; + const distance = Math.hypot(dx, dy); // Sticky headers on Android can emit pressIn/pressOut without onPress. // Treat short, low-movement interactions as taps. if (durationMs <= 500 && distance <= 12) { - toggleExpanded() + toggleExpanded(); } } }} onPress={toggleExpanded} > - {file.path.split('/').pop()} + {file.path.split("/").pop()} - {file.path.includes('/') ? ` ${file.path.slice(0, file.path.lastIndexOf('/'))}` : ''} + {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} {file.isNew && ( @@ -246,8 +246,8 @@ const DiffFileHeader = memo(function DiffFileHeader({ - ) -}) + ); +}); function DiffFileBody({ file, @@ -255,93 +255,93 @@ function DiffFileBody({ onBodyHeightChange, testID, }: { - file: ParsedDiffFile - wrapLines: boolean - onBodyHeightChange?: (path: string, height: number) => void - testID?: string + file: ParsedDiffFile; + wrapLines: boolean; + onBodyHeightChange?: (path: string, height: number) => void; + testID?: string; }) { - const [scrollViewWidth, setScrollViewWidth] = useState(0) - const [isAtLeftEdge, setIsAtLeftEdge] = useState(true) - const horizontalScroll = useHorizontalScrollOptional() - const scrollId = useId() - const scrollViewRef = useRef(null) + const [scrollViewWidth, setScrollViewWidth] = useState(0); + const [isAtLeftEdge, setIsAtLeftEdge] = useState(true); + const horizontalScroll = useHorizontalScrollOptional(); + const scrollId = useId(); + const scrollViewRef = useRef(null); // Get the close gesture ref from animation context (may not be available outside sidebar) - let closeGestureRef: React.MutableRefObject | undefined + let closeGestureRef: React.MutableRefObject | undefined; try { - const animation = useExplorerSidebarAnimation() - closeGestureRef = animation.closeGestureRef + const animation = useExplorerSidebarAnimation(); + closeGestureRef = animation.closeGestureRef; } catch { // Not inside ExplorerSidebarAnimationProvider, which is fine } // Register/unregister scroll offset tracking useEffect(() => { - if (!horizontalScroll) return + if (!horizontalScroll) return; // Start at 0 (not scrolled) - horizontalScroll.registerScrollOffset(scrollId, 0) + horizontalScroll.registerScrollOffset(scrollId, 0); return () => { - horizontalScroll.unregisterScrollOffset(scrollId) - } - }, [horizontalScroll, scrollId]) + horizontalScroll.unregisterScrollOffset(scrollId); + }; + }, [horizontalScroll, scrollId]); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { - const offsetX = event.nativeEvent.contentOffset.x + const offsetX = event.nativeEvent.contentOffset.x; // Track if we're at the left edge (with small threshold for float precision) - setIsAtLeftEdge(offsetX <= 1) + setIsAtLeftEdge(offsetX <= 1); if (horizontalScroll) { - horizontalScroll.registerScrollOffset(scrollId, offsetX) + horizontalScroll.registerScrollOffset(scrollId, offsetX); } }, - [horizontalScroll, scrollId] - ) + [horizontalScroll, scrollId], + ); return ( { - onBodyHeightChange?.(file.path, event.nativeEvent.layout.height) + onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); }} testID={testID} > {(() => { - if (file.status === 'too_large' || file.status === 'binary') { + if (file.status === "too_large" || file.status === "binary") { return ( - {file.status === 'binary' ? 'Binary file' : 'Diff too large to display'} + {file.status === "binary" ? "Binary file" : "Diff too large to display"} - ) + ); } const linesContent = (() => { - let maxLineNo = 0 + let maxLineNo = 0; for (const hunk of file.hunks) { maxLineNo = Math.max( maxLineNo, hunk.oldStart + hunk.oldCount, - hunk.newStart + hunk.newCount - ) + hunk.newStart + hunk.newCount, + ); } - const digitCount = Math.max(1, String(maxLineNo).length) - const gutterWidth = digitCount * 8 + 12 + const digitCount = Math.max(1, String(maxLineNo).length); + const gutterWidth = digitCount * 8 + 12; return file.hunks.map((hunk, hunkIndex) => { - let oldLineNo = hunk.oldStart - let newLineNo = hunk.newStart + let oldLineNo = hunk.oldStart; + let newLineNo = hunk.newStart; return hunk.lines.map((line, lineIndex) => { - let lineNumber: number | null = null - if (line.type === 'remove') { - lineNumber = oldLineNo - oldLineNo++ - } else if (line.type === 'add') { - lineNumber = newLineNo - newLineNo++ - } else if (line.type === 'context') { - lineNumber = newLineNo - oldLineNo++ - newLineNo++ + let lineNumber: number | null = null; + if (line.type === "remove") { + lineNumber = oldLineNo; + oldLineNo++; + } else if (line.type === "add") { + lineNumber = newLineNo; + newLineNo++; + } else if (line.type === "context") { + lineNumber = newLineNo; + oldLineNo++; + newLineNo++; } return ( - ) - }) - }) - })() + ); + }); + }); + })(); if (wrapLines) { return ( {linesContent} - ) + ); } return ( @@ -387,47 +387,47 @@ function DiffFileBody({ {linesContent} - ) + ); })()} - ) + ); } interface GitDiffPaneProps { - serverId: string - workspaceId?: string | null - cwd: string - hideHeaderRow?: boolean + serverId: string; + workspaceId?: string | null; + cwd: string; + hideHeaderRow?: boolean; } type DiffFlatItem = - | { type: 'header'; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } - | { type: 'body'; file: ParsedDiffFile; fileIndex: number } + | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } + | { type: "body"; file: ParsedDiffFile; fileIndex: number }; export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) { - const { theme } = useUnistyles() - const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile - const router = useRouter() - const [diffModeOverride, setDiffModeOverride] = useState<'uncommitted' | 'base' | null>(null) - const [actionError, setActionError] = useState(null) - const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false) - const [shipDefault, setShipDefault] = useState<'merge' | 'pr'>('merge') - const [wrapLines, setWrapLines] = useState(false) + const { theme } = useUnistyles(); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; + const router = useRouter(); + const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); + const [actionError, setActionError] = useState(null); + const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); + const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); + const [wrapLines, setWrapLines] = useState(false); useEffect(() => { - AsyncStorage.getItem('diff-wrap-lines').then((value) => { - if (value === 'true') setWrapLines(true) - }) - }, []) + AsyncStorage.getItem("diff-wrap-lines").then((value) => { + if (value === "true") setWrapLines(true); + }); + }, []); const handleToggleWrapLines = useCallback(() => { setWrapLines((prev) => { - const next = !prev - AsyncStorage.setItem('diff-wrap-lines', String(next)) - return next - }) - }, []) + const next = !prev; + AsyncStorage.setItem("diff-wrap-lines", String(next)); + return next; + }); + }, []); const { status, @@ -436,19 +436,19 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi isError: isStatusError, error: statusError, refresh: refreshStatus, - } = useCheckoutStatusQuery({ serverId, cwd }) - const gitStatus = status && status.isGit ? status : null - const isGit = Boolean(gitStatus) - const notGit = status !== null && !status.isGit && !status.error + } = useCheckoutStatusQuery({ serverId, cwd }); + const gitStatus = status && status.isGit ? status : null; + const isGit = Boolean(gitStatus); + const notGit = status !== null && !status.isGit && !status.error; const statusErrorMessage = status?.error?.message ?? - (isStatusError && statusError instanceof Error ? statusError.message : null) - const baseRef = gitStatus?.baseRef ?? undefined + (isStatusError && statusError instanceof Error ? statusError.message : null); + const baseRef = gitStatus?.baseRef ?? undefined; // Auto-select diff mode based on state: uncommitted when dirty, base when clean - const hasUncommittedChanges = Boolean(gitStatus?.isDirty) - const autoDiffMode = hasUncommittedChanges ? 'uncommitted' : 'base' - const diffMode = diffModeOverride ?? autoDiffMode + const hasUncommittedChanges = Boolean(gitStatus?.isDirty); + const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base"; + const diffMode = diffModeOverride ?? autoDiffMode; const { files, @@ -464,7 +464,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi mode: diffMode, baseRef, enabled: isGit, - }) + }); const { status: prStatus, githubFeaturesEnabled, @@ -474,142 +474,142 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi serverId, cwd, enabled: isGit, - }) + }); // Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches - const [isManualRefresh, setIsManualRefresh] = useState(false) - const [expandedByPath, setExpandedByPath] = useState>({}) - const diffListRef = useRef>(null) - const diffScrollbarMetrics = useWebDesktopScrollbarMetrics() - const diffListScrollOffsetRef = useRef(0) - const diffListViewportHeightRef = useRef(0) - const headerHeightByPathRef = useRef>({}) - const bodyHeightByPathRef = useRef>({}) - const defaultHeaderHeightRef = useRef(44) + const [isManualRefresh, setIsManualRefresh] = useState(false); + const [expandedByPath, setExpandedByPath] = useState>({}); + const diffListRef = useRef>(null); + const diffScrollbarMetrics = useWebDesktopScrollbarMetrics(); + const diffListScrollOffsetRef = useRef(0); + const diffListViewportHeightRef = useRef(0); + const headerHeightByPathRef = useRef>({}); + const bodyHeightByPathRef = useRef>({}); + const defaultHeaderHeightRef = useRef(44); const handleRefresh = useCallback(() => { - setIsManualRefresh(true) - void refreshDiff() - void refreshStatus() - void refreshPrStatus() - }, [refreshDiff, refreshStatus, refreshPrStatus]) + setIsManualRefresh(true); + void refreshDiff(); + void refreshStatus(); + void refreshPrStatus(); + }, [refreshDiff, refreshStatus, refreshPrStatus]); const shipDefaultStorageKey = useMemo(() => { if (!gitStatus?.repoRoot) { - return null + return null; } - return `@paseo:changes-ship-default:${gitStatus.repoRoot}` - }, [gitStatus?.repoRoot]) + return `@paseo:changes-ship-default:${gitStatus.repoRoot}`; + }, [gitStatus?.repoRoot]); useEffect(() => { if (!shipDefaultStorageKey) { - return + return; } - let isActive = true + let isActive = true; AsyncStorage.getItem(shipDefaultStorageKey) .then((value) => { - if (!isActive) return - if (value === 'pr' || value === 'merge') { - setShipDefault(value) + if (!isActive) return; + if (value === "pr" || value === "merge") { + setShipDefault(value); } }) - .catch(() => undefined) + .catch(() => undefined); return () => { - isActive = false - } - }, [shipDefaultStorageKey]) + isActive = false; + }; + }, [shipDefaultStorageKey]); const persistShipDefault = useCallback( - async (next: 'merge' | 'pr') => { - setShipDefault(next) - if (!shipDefaultStorageKey) return + async (next: "merge" | "pr") => { + setShipDefault(next); + if (!shipDefaultStorageKey) return; try { - await AsyncStorage.setItem(shipDefaultStorageKey, next) + await AsyncStorage.setItem(shipDefaultStorageKey, next); } catch { // Ignore persistence failures; default will reset to "merge". } }, - [shipDefaultStorageKey] - ) + [shipDefaultStorageKey], + ); const { flatItems, stickyHeaderIndices } = useMemo(() => { - const items: DiffFlatItem[] = [] - const stickyIndices: number[] = [] + const items: DiffFlatItem[] = []; + const stickyIndices: number[] = []; for (let i = 0; i < files.length; i++) { - const file = files[i] - const isExpanded = expandedByPath[file.path] ?? false - items.push({ type: 'header', file, fileIndex: i, isExpanded }) + const file = files[i]; + const isExpanded = expandedByPath[file.path] ?? false; + items.push({ type: "header", file, fileIndex: i, isExpanded }); if (isExpanded) { - stickyIndices.push(items.length - 1) + stickyIndices.push(items.length - 1); } if (isExpanded) { - items.push({ type: 'body', file, fileIndex: i }) + items.push({ type: "body", file, fileIndex: i }); } } - return { flatItems: items, stickyHeaderIndices: stickyIndices } - }, [files, expandedByPath]) + return { flatItems: items, stickyHeaderIndices: stickyIndices }; + }, [files, expandedByPath]); const handleHeaderHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height <= 0) { - return + return; } - headerHeightByPathRef.current[path] = height - defaultHeaderHeightRef.current = height - }, []) + headerHeightByPathRef.current[path] = height; + defaultHeaderHeightRef.current = height; + }, []); const handleBodyHeightChange = useCallback((path: string, height: number) => { if (!Number.isFinite(height) || height < 0) { - return + return; } - bodyHeightByPathRef.current[path] = height - }, []) + bodyHeightByPathRef.current[path] = height; + }, []); const handleDiffListScroll = useCallback( (event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; if (showDesktopWebScrollbar) { - diffScrollbarMetrics.onScroll(event) + diffScrollbarMetrics.onScroll(event); } }, - [diffScrollbarMetrics, showDesktopWebScrollbar] - ) + [diffScrollbarMetrics, showDesktopWebScrollbar], + ); const handleDiffListLayout = useCallback( (event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height + const height = event.nativeEvent.layout.height; if (!Number.isFinite(height) || height <= 0) { - return + return; } - diffListViewportHeightRef.current = height + diffListViewportHeightRef.current = height; if (showDesktopWebScrollbar) { - diffScrollbarMetrics.onLayout(event) + diffScrollbarMetrics.onLayout(event); } }, - [diffScrollbarMetrics, showDesktopWebScrollbar] - ) + [diffScrollbarMetrics, showDesktopWebScrollbar], + ); const computeHeaderOffset = useCallback( (path: string): number => { - const defaultHeaderHeight = defaultHeaderHeightRef.current - let offset = 0 + const defaultHeaderHeight = defaultHeaderHeightRef.current; + let offset = 0; for (const file of files) { if (file.path === path) { - break + break; } - offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight + offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight; if (expandedByPath[file.path]) { - offset += bodyHeightByPathRef.current[file.path] ?? 0 + offset += bodyHeightByPathRef.current[file.path] ?? 0; } } - return Math.max(0, offset) + return Math.max(0, offset); }, - [expandedByPath, files] - ) + [expandedByPath, files], + ); const handleToggleExpanded = useCallback( (path: string) => { - const isCurrentlyExpanded = expandedByPath[path] ?? false - const nextExpanded = !isCurrentlyExpanded - const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null - const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current + const isCurrentlyExpanded = expandedByPath[path] ?? false; + const nextExpanded = !isCurrentlyExpanded; + const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; + const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current; const shouldAnchor = isCurrentlyExpanded && targetOffset !== null && @@ -618,14 +618,14 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi headerHeight, viewportOffset: diffListScrollOffsetRef.current, viewportHeight: diffListViewportHeightRef.current, - }) + }); // Anchor to the clicked header before collapsing so visual context is preserved. if (shouldAnchor && targetOffset !== null) { diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false, - }) + }); } setExpandedByPath((prev) => ({ @@ -633,141 +633,141 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi // Use a deterministic target value (instead of toggling from prev) so duplicate // onPress events from sticky headers on Android can't flip back immediately. [path]: nextExpanded, - })) + })); }, - [computeHeaderOffset, expandedByPath] - ) + [computeHeaderOffset, expandedByPath], + ); const allExpanded = useMemo(() => { - if (files.length === 0) return false - return files.every((file) => expandedByPath[file.path]) - }, [files, expandedByPath]) + if (files.length === 0) return false; + return files.every((file) => expandedByPath[file.path]); + }, [files, expandedByPath]); const handleToggleExpandAll = useCallback(() => { if (allExpanded) { - setExpandedByPath({}) + setExpandedByPath({}); } else { - const newExpanded: Record = {} + const newExpanded: Record = {}; for (const file of files) { - newExpanded[file.path] = true + newExpanded[file.path] = true; } - setExpandedByPath(newExpanded) + setExpandedByPath(newExpanded); } - }, [allExpanded, files]) + }, [allExpanded, files]); // Reset manual refresh flag when fetch completes useEffect(() => { if (!(isDiffFetching || isStatusFetching) && isManualRefresh) { - setIsManualRefresh(false) + setIsManualRefresh(false); } - }, [isDiffFetching, isStatusFetching, isManualRefresh]) + }, [isDiffFetching, isStatusFetching, isManualRefresh]); // Clear diff mode override when auto mode changes (e.g., after commit) useEffect(() => { - setDiffModeOverride(null) - }, [autoDiffMode]) + setDiffModeOverride(null); + }, [autoDiffMode]); const commitStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: 'commit' }) - ) + state.getStatus({ serverId, cwd, actionId: "commit" }), + ); const pushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: 'push' }) - ) + state.getStatus({ serverId, cwd, actionId: "push" }), + ); const prCreateStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: 'create-pr' }) - ) + state.getStatus({ serverId, cwd, actionId: "create-pr" }), + ); const mergeStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: 'merge-branch' }) - ) + state.getStatus({ serverId, cwd, actionId: "merge-branch" }), + ); const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: 'merge-from-base' }) - ) + state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), + ); const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: 'archive-worktree' }) - ) + state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), + ); - const runCommit = useCheckoutGitActionsStore((state) => state.commit) - const runPush = useCheckoutGitActionsStore((state) => state.push) - const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr) - const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch) - const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase) - const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) + const runCommit = useCheckoutGitActionsStore((state) => state.commit); + const runPush = useCheckoutGitActionsStore((state) => state.push); + const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr); + const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch); + const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase); + const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const handleCommit = useCallback(() => { - setActionError(null) + setActionError(null); void runCommit({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to commit' - setActionError(message) - }) - }, [runCommit, serverId, cwd]) + const message = err instanceof Error ? err.message : "Failed to commit"; + setActionError(message); + }); + }, [runCommit, serverId, cwd]); const handlePush = useCallback(() => { - setActionError(null) + setActionError(null); void runPush({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to push' - setActionError(message) - }) - }, [runPush, serverId, cwd]) + const message = err instanceof Error ? err.message : "Failed to push"; + setActionError(message); + }); + }, [runPush, serverId, cwd]); const handleCreatePr = useCallback(() => { - void persistShipDefault('pr') - setActionError(null) + void persistShipDefault("pr"); + setActionError(null); void runCreatePr({ serverId, cwd }).catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to create PR' - setActionError(message) - }) - }, [persistShipDefault, runCreatePr, serverId, cwd]) + const message = err instanceof Error ? err.message : "Failed to create PR"; + setActionError(message); + }); + }, [persistShipDefault, runCreatePr, serverId, cwd]); const handleMergeBranch = useCallback(() => { if (!baseRef) { - setActionError('Base ref unavailable') - return + setActionError("Base ref unavailable"); + return; } - void persistShipDefault('merge') - setActionError(null) + void persistShipDefault("merge"); + setActionError(null); void runMergeBranch({ serverId, cwd, baseRef }) .then(() => { - setPostShipArchiveSuggested(true) + setPostShipArchiveSuggested(true); }) .catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to merge' - setActionError(message) - }) - }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]) + const message = err instanceof Error ? err.message : "Failed to merge"; + setActionError(message); + }); + }, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]); const handleMergeFromBase = useCallback(() => { if (!baseRef) { - setActionError('Base ref unavailable') - return + setActionError("Base ref unavailable"); + return; } - setActionError(null) + setActionError(null); void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to merge from base' - setActionError(message) - }) - }, [baseRef, runMergeFromBase, serverId, cwd]) + const message = err instanceof Error ? err.message : "Failed to merge from base"; + setActionError(message); + }); + }, [baseRef, runMergeFromBase, serverId, cwd]); const handleArchiveWorktree = useCallback(() => { - const worktreePath = status?.cwd + const worktreePath = status?.cwd; if (!worktreePath) { - setActionError('Worktree path unavailable') - return + setActionError("Worktree path unavailable"); + return; } - setActionError(null) - const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null) + setActionError(null); + const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null); void runArchiveWorktree({ serverId, cwd, worktreePath }) .then(() => { - router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any) + router.replace(buildNewAgentRoute(serverId, targetWorkingDir) as any); }) .catch((err) => { - const message = err instanceof Error ? err.message : 'Failed to archive worktree' - setActionError(message) - }) - }, [runArchiveWorktree, router, serverId, cwd, status]) + const message = err instanceof Error ? err.message : "Failed to archive worktree"; + setActionError(message); + }); + }, [runArchiveWorktree, router, serverId, cwd, status]); const renderFlatItem = useCallback( ({ item }: { item: DiffFlatItem }) => { - if (item.type === 'header') { + if (item.type === "header") { return ( - ) + ); } return ( - ) + ); }, - [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines] - ) + [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines], + ); - const flatKeyExtractor = useCallback((item: DiffFlatItem) => `${item.type}-${item.file.path}`, []) + const flatKeyExtractor = useCallback( + (item: DiffFlatItem) => `${item.type}-${item.file.path}`, + [], + ); - const hasChanges = files.length > 0 + const hasChanges = files.length > 0; const diffErrorMessage = diffPayloadError?.message ?? - (isDiffError && diffError instanceof Error ? diffError.message : null) - const prErrorMessage = githubFeaturesEnabled ? prPayloadError?.message ?? null : null + (isDiffError && diffError instanceof Error ? diffError.message : null); + const prErrorMessage = githubFeaturesEnabled ? (prPayloadError?.message ?? null) : null; const branchLabel = - gitStatus?.currentBranch && gitStatus.currentBranch !== 'HEAD' + gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" ? gitStatus.currentBranch : notGit - ? 'Not a git repository' - : 'Unknown' - const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading - const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0 - const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0 - const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0 + ? "Not a git repository" + : "Unknown"; + const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; + const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; + const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0; + const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0; const baseRefLabel = useMemo(() => { - if (!baseRef) return 'base' - const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, '').trim() - return trimmed.startsWith('origin/') ? trimmed.slice('origin/'.length) : trimmed - }, [baseRef]) + if (!baseRef) return "base"; + const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim(); + return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; + }, [baseRef]); const committedDiffDescription = useMemo(() => { if (!branchLabel || !baseRefLabel) { - return undefined + return undefined; } - return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}` - }, [baseRefLabel, branchLabel]) - const hasPullRequest = Boolean(prStatus?.url) - const hasRemote = gitStatus?.hasRemote ?? false - const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false - const isMergedPullRequest = Boolean(prStatus?.isMerged) - const currentBranch = gitStatus?.currentBranch - const isOnBaseBranch = currentBranch === baseRefLabel + return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`; + }, [baseRefLabel, branchLabel]); + const hasPullRequest = Boolean(prStatus?.url); + const hasRemote = gitStatus?.hasRemote ?? false; + const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false; + const isMergedPullRequest = Boolean(prStatus?.isMerged); + const currentBranch = gitStatus?.currentBranch; + const isOnBaseBranch = currentBranch === baseRefLabel; const shouldPromoteArchive = isPaseoOwnedWorktree && !hasUncommittedChanges && - (postShipArchiveSuggested || isMergedPullRequest) + (postShipArchiveSuggested || isMergedPullRequest); - const commitDisabled = actionsDisabled || commitStatus === 'pending' - const prDisabled = actionsDisabled || prCreateStatus === 'pending' - const mergeDisabled = actionsDisabled || mergeStatus === 'pending' - const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === 'pending' - const pushDisabled = actionsDisabled || pushStatus === 'pending' - const archiveDisabled = actionsDisabled || archiveStatus === 'pending' + const commitDisabled = actionsDisabled || commitStatus === "pending"; + const prDisabled = actionsDisabled || prCreateStatus === "pending"; + const mergeDisabled = actionsDisabled || mergeStatus === "pending"; + const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; + const pushDisabled = actionsDisabled || pushStatus === "pending"; + const archiveDisabled = actionsDisabled || archiveStatus === "pending"; - let bodyContent: ReactElement + let bodyContent: ReactElement; if (isStatusLoading) { bodyContent = ( @@ -844,39 +847,39 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi Checking repository... - ) + ); } else if (statusErrorMessage) { bodyContent = ( {statusErrorMessage} - ) + ); } else if (notGit) { bodyContent = ( Not a git repository - ) + ); } else if (isDiffLoading) { bodyContent = ( - ) + ); } else if (diffErrorMessage) { bodyContent = ( {diffErrorMessage} - ) + ); } else if (!hasChanges) { bodyContent = ( - {diffMode === 'uncommitted' ? 'No uncommitted changes' : `No changes vs ${baseRefLabel}`} + {diffMode === "uncommitted" ? "No uncommitted changes" : `No changes vs ${baseRefLabel}`} - ) + ); } else { bodyContent = ( - ) + ); } useEffect(() => { - setPostShipArchiveSuggested(false) - }, [cwd]) + setPostShipArchiveSuggested(false); + }, [cwd]); // ========================================================================== // Git Actions (Data-Oriented) @@ -953,36 +956,36 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi }, pr: { disabled: prDisabled, - status: hasPullRequest ? 'idle' : prCreateStatus, + status: hasPullRequest ? "idle" : prCreateStatus, icon: , handler: () => { if (prStatus?.url) { - openURLInNewTab(prStatus.url) - return + openURLInNewTab(prStatus.url); + return; } - handleCreatePr() + handleCreatePr(); }, }, - 'merge-branch': { + "merge-branch": { disabled: mergeDisabled, status: mergeStatus, icon: , handler: handleMergeBranch, }, - 'merge-from-base': { + "merge-from-base": { disabled: mergeFromBaseDisabled, status: mergeFromBaseStatus, icon: , handler: handleMergeFromBase, }, - 'archive-worktree': { + "archive-worktree": { disabled: archiveDisabled, status: archiveStatus, icon: , handler: handleArchiveWorktree, }, }, - }) + }); }, [ isGit, hasRemote, @@ -1017,7 +1020,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi handleMergeFromBase, handleArchiveWorktree, theme.colors.foregroundMuted, - ]) + ]); // Helper to get display label based on status @@ -1050,24 +1053,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi accessibilityLabel="Diff mode" > - {diffMode === 'uncommitted' ? 'Uncommitted' : 'Committed'} + {diffMode === "uncommitted" ? "Uncommitted" : "Committed"} setDiffModeOverride('uncommitted')} + selected={diffMode === "uncommitted"} + onSelect={() => setDiffModeOverride("uncommitted")} > Uncommitted setDiffModeOverride('base')} + onSelect={() => setDiffModeOverride("base")} > Committed @@ -1089,7 +1092,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi - {wrapLines ? 'Scroll long lines' : 'Wrap long lines'} + {wrapLines ? "Scroll long lines" : "Wrap long lines"} @@ -1117,7 +1120,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi - {allExpanded ? 'Collapse all files' : 'Expand all files'} + {allExpanded ? "Collapse all files" : "Expand all files"} @@ -1139,12 +1142,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi diffListRef.current?.scrollToOffset({ offset: nextOffset, animated: false, - }) + }); }} /> - ) + ); } const styles = StyleSheet.create((theme) => ({ @@ -1153,9 +1156,9 @@ const styles = StyleSheet.create((theme) => ({ minHeight: 0, }, header: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], @@ -1163,8 +1166,8 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.border, }, headerLeft: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, @@ -1182,14 +1185,14 @@ const styles = StyleSheet.create((theme) => ({ }, diffStatusInner: { flex: 1, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingRight: theme.spacing[3], }, diffModeTrigger: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], // Align text with header branch icon (at spacing[3] from edge, minus our horizontal padding) marginLeft: theme.spacing[3] - theme.spacing[1], @@ -1215,8 +1218,8 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0, }, diffStatusButtons: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: { xs: theme.spacing[1], sm: theme.spacing[1], @@ -1224,8 +1227,8 @@ const styles = StyleSheet.create((theme) => ({ }, }, expandAllButton: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], marginVertical: theme.spacing[2], paddingHorizontal: { @@ -1249,7 +1252,7 @@ const styles = StyleSheet.create((theme) => ({ diffContainer: { flex: 1, minHeight: 0, - position: 'relative', + position: "relative", }, scrollView: { flex: 1, @@ -1259,8 +1262,8 @@ const styles = StyleSheet.create((theme) => ({ }, loadingContainer: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", paddingTop: theme.spacing[16], gap: theme.spacing[4], }, @@ -1270,20 +1273,20 @@ const styles = StyleSheet.create((theme) => ({ }, errorContainer: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", paddingTop: theme.spacing[16], paddingHorizontal: theme.spacing[6], }, errorText: { fontSize: theme.fontSize.base, color: theme.colors.destructive, - textAlign: 'center', + textAlign: "center", }, emptyContainer: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", paddingTop: theme.spacing[16], }, emptyText: { @@ -1291,19 +1294,19 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, }, fileSection: { - overflow: 'hidden', + overflow: "hidden", backgroundColor: theme.colors.surface2, borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, fileSectionHeaderContainer: { - overflow: 'hidden', + overflow: "hidden", }, fileSectionHeaderExpanded: { backgroundColor: theme.colors.surface1, }, fileSectionBodyContainer: { - overflow: 'hidden', + overflow: "hidden", backgroundColor: theme.colors.surface2, }, fileSectionBorder: { @@ -1311,9 +1314,9 @@ const styles = StyleSheet.create((theme) => ({ borderBottomColor: theme.colors.border, }, fileHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingLeft: theme.spacing[3], paddingRight: theme.spacing[2], paddingVertical: theme.spacing[2], @@ -1325,15 +1328,15 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.7, }, fileHeaderLeft: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, }, fileHeaderRight: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], flexShrink: 0, }, @@ -1350,7 +1353,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, }, newBadge: { - backgroundColor: 'rgba(46, 160, 67, 0.2)', + backgroundColor: "rgba(46, 160, 67, 0.2)", paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.md, @@ -1362,7 +1365,7 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.palette.green[400], }, deletedBadge: { - backgroundColor: 'rgba(248, 81, 73, 0.2)', + backgroundColor: "rgba(248, 81, 73, 0.2)", paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.md, @@ -1389,24 +1392,24 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface1, }, diffContentInner: { - flexDirection: 'column', + flexDirection: "column", }, linesContainer: { backgroundColor: theme.colors.surface1, }, diffLineContainer: { - flexDirection: 'row', - alignItems: 'stretch', + flexDirection: "row", + alignItems: "stretch", }, lineNumberGutter: { borderRightWidth: theme.borderWidth[1], borderRightColor: theme.colors.border, marginRight: theme.spacing[2], - alignSelf: 'stretch', - justifyContent: 'center', + alignSelf: "stretch", + justifyContent: "center", }, lineNumberText: { - textAlign: 'right', + textAlign: "right", paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], fontSize: theme.fontSize.xs, @@ -1428,13 +1431,13 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, }, addLineContainer: { - backgroundColor: 'rgba(46, 160, 67, 0.15)', // GitHub green + backgroundColor: "rgba(46, 160, 67, 0.15)", // GitHub green }, addLineText: { color: theme.colors.foreground, }, removeLineContainer: { - backgroundColor: 'rgba(248, 81, 73, 0.1)', // GitHub red + backgroundColor: "rgba(248, 81, 73, 0.1)", // GitHub red }, removeLineText: { color: theme.colors.foreground, @@ -1461,10 +1464,10 @@ const styles = StyleSheet.create((theme) => ({ statusMessageText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, - fontStyle: 'italic', + fontStyle: "italic", }, tooltipText: { fontSize: theme.fontSize.xs, color: theme.colors.foreground, }, -})) +})); diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index cdeb4717a..e12cedcc4 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -11,21 +11,14 @@ interface BackHeaderProps { onBack?: () => void; } -export function BackHeader({ - title, - rightContent, - onBack, -}: BackHeaderProps) { +export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { const { theme } = useUnistyles(); return ( - router.back())} - style={styles.backButton} - > + router.back())} style={styles.backButton}> {title && ( diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index a31dfbf28..f6a8623f6 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -29,11 +29,7 @@ function MobileMenuIcon({ color }: { color: string }) { ); @@ -46,17 +42,15 @@ export function SidebarMenuToggle({ nativeID = "menu-button", }: SidebarMenuToggleProps = {}) { const { theme } = useUnistyles(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]; const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; - const menuIconColor = !isMobile && isOpen - ? theme.colors.foreground - : theme.colors.foregroundMuted; + const menuIconColor = + !isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted; return ( {/* Plus */} - - + + {/* Minus */} - + ); } diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx index a2c7ebddd..bd0350d89 100644 --- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -16,7 +16,7 @@ export function KeyboardShortcutsDialog() { const isDesktopApp = getIsDesktop(); const sections = useMemo( () => buildKeyboardShortcutHelpSections({ isMac, isDesktop: isDesktopApp }), - [isDesktopApp, isMac] + [isDesktopApp, isMac], ); return ( diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 20eb2e3e8..49bc40606 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -9,103 +9,108 @@ import { type Dispatch, type RefObject, type SetStateAction, -} from 'react' -import { View, Pressable, Text, Platform } from 'react-native' -import { useSafeAreaInsets } from 'react-native-safe-area-context' +} from "react"; +import { View, Pressable, Text, Platform } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, interpolate, Extrapolation, runOnJS, useSharedValue, -} from 'react-native-reanimated' -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' -import { MessagesSquare, Plus, Settings } from 'lucide-react-native' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Shortcut } from '@/components/ui/shortcut' -import { router, usePathname } from 'expo-router' -import { usePanelStore } from '@/stores/panel-store' -import { SidebarWorkspaceList } from './sidebar-workspace-list' -import { SidebarAgentListSkeleton } from './sidebar-agent-list-skeleton' -import { useSidebarShortcutModel } from '@/hooks/use-sidebar-shortcut-model' -import { useSidebarWorkspacesList, type SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list' -import { useSidebarAnimation } from '@/contexts/sidebar-animation-context' -import { useDesktopDragHandlers, useTrafficLightPadding } from '@/utils/desktop-window' -import { Combobox } from '@/components/ui/combobox' -import { getHostRuntimeStore, useHosts } from '@/runtime/host-runtime' -import { formatConnectionStatus } from '@/utils/daemons' -import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from '@/constants/layout' +} from "react-native-reanimated"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { MessagesSquare, Plus, Settings } from "lucide-react-native"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Shortcut } from "@/components/ui/shortcut"; +import { router, usePathname } from "expo-router"; +import { usePanelStore } from "@/stores/panel-store"; +import { SidebarWorkspaceList } from "./sidebar-workspace-list"; +import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; +import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model"; +import { + useSidebarWorkspacesList, + type SidebarProjectEntry, +} from "@/hooks/use-sidebar-workspaces-list"; +import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; +import { useDesktopDragHandlers, useTrafficLightPadding } from "@/utils/desktop-window"; +import { Combobox } from "@/components/ui/combobox"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { formatConnectionStatus } from "@/utils/daemons"; +import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; import { buildHostSessionsRoute, buildHostSettingsRoute, mapPathnameToServer, parseServerIdFromPathname, -} from '@/utils/host-routes' -import { useOpenProjectPicker } from '@/hooks/use-open-project-picker' +} from "@/utils/host-routes"; +import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; -const DESKTOP_SIDEBAR_WIDTH = 320 -type SidebarShortcutModel = ReturnType -type SidebarTheme = ReturnType['theme'] +const DESKTOP_SIDEBAR_WIDTH = 320; +type SidebarShortcutModel = ReturnType; +type SidebarTheme = ReturnType["theme"]; interface LeftSidebarProps { - selectedAgentId?: string + selectedAgentId?: string; } interface HostOption { - id: string - label: string - description: string + id: string; + label: string; + description: string; } interface SidebarSharedProps { - theme: SidebarTheme - activeServerId: string | null - activeHostLabel: string - activeHostStatusColor: string - hostOptions: HostOption[] - hostTriggerRef: RefObject - isHostPickerOpen: boolean - setIsHostPickerOpen: Dispatch> - projects: SidebarProjectEntry[] - isInitialLoad: boolean - isRevalidating: boolean - isManualRefresh: boolean - collapsedProjectKeys: SidebarShortcutModel['collapsedProjectKeys'] - shortcutIndexByWorkspaceKey: SidebarShortcutModel['shortcutIndexByWorkspaceKey'] - toggleProjectCollapsed: SidebarShortcutModel['toggleProjectCollapsed'] - setProjectCollapsed: SidebarShortcutModel['setProjectCollapsed'] - handleRefresh: () => void - handleHostSelect: (nextServerId: string) => void - handleOpenProject: () => void - handleSettings: () => void + theme: SidebarTheme; + activeServerId: string | null; + activeHostLabel: string; + activeHostStatusColor: string; + hostOptions: HostOption[]; + hostTriggerRef: RefObject; + isHostPickerOpen: boolean; + setIsHostPickerOpen: Dispatch>; + projects: SidebarProjectEntry[]; + isInitialLoad: boolean; + isRevalidating: boolean; + isManualRefresh: boolean; + collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"]; + shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"]; + toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"]; + setProjectCollapsed: SidebarShortcutModel["setProjectCollapsed"]; + handleRefresh: () => void; + handleHostSelect: (nextServerId: string) => void; + handleOpenProject: () => void; + handleSettings: () => void; } interface MobileSidebarProps extends SidebarSharedProps { - insetsTop: number - insetsBottom: number - isOpen: boolean - closeToAgent: () => void - handleViewMoreNavigate: () => void + insetsTop: number; + insetsBottom: number; + isOpen: boolean; + closeToAgent: () => void; + handleViewMoreNavigate: () => void; } interface DesktopSidebarProps extends SidebarSharedProps { - isOpen: boolean - handleViewMore: () => void + isOpen: boolean; + handleViewMore: () => void; } -export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarProps) { - void _selectedAgentId +export const LeftSidebar = memo(function LeftSidebar({ + selectedAgentId: _selectedAgentId, +}: LeftSidebarProps) { + void _selectedAgentId; - const { theme } = useUnistyles() - const insets = useSafeAreaInsets() - const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const mobileView = usePanelStore((state) => state.mobileView) - const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen) - const closeToAgent = usePanelStore((state) => state.closeToAgent) - const pathname = usePathname() - const daemons = useHosts() - const runtime = getHostRuntimeStore() + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); + const closeToAgent = usePanelStore((state) => state.closeToAgent); + const pathname = usePathname(); + const daemons = useHosts(); + const runtime = getHostRuntimeStore(); const runtimeConnectionStatusSignature = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => @@ -113,128 +118,132 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte .map( (daemon) => `${daemon.serverId}:${ - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' - }` + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" + }`, ) - .join('|'), + .join("|"), () => daemons .map( (daemon) => `${daemon.serverId}:${ - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' - }` + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting" + }`, ) - .join('|') - ) - const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]) + .join("|"), + ); + const activeServerIdFromPath = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); const activeDaemon = useMemo(() => { if (daemons.length === 0) { - return null + return null; } if (activeServerIdFromPath) { - const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath) + const routeMatch = daemons.find((entry) => entry.serverId === activeServerIdFromPath); if (routeMatch) { - return routeMatch + return routeMatch; } } - return daemons[0] ?? null - }, [activeServerIdFromPath, daemons]) - const activeServerId = activeDaemon?.serverId ?? null + return daemons[0] ?? null; + }, [activeServerIdFromPath, daemons]); + const activeServerId = activeDaemon?.serverId ?? null; const activeHostLabel = useMemo(() => { - if (!activeDaemon) return 'No host' - const trimmed = activeDaemon.label?.trim() - return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId - }, [activeDaemon]) + if (!activeDaemon) return "No host"; + const trimmed = activeDaemon.label?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : activeDaemon.serverId; + }, [activeDaemon]); const activeHostStatus = activeServerId - ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? 'connecting') - : 'idle' + ? (runtime.getSnapshot(activeServerId)?.connectionStatus ?? "connecting") + : "idle"; const activeHostStatusColor = - activeHostStatus === 'online' + activeHostStatus === "online" ? theme.colors.palette.green[400] - : activeHostStatus === 'connecting' + : activeHostStatus === "connecting" ? theme.colors.palette.amber[500] - : theme.colors.palette.red[500] + : theme.colors.palette.red[500]; const hostOptions = useMemo( () => daemons.map((daemon) => ({ id: daemon.serverId, label: daemon.label?.trim() || daemon.serverId, description: formatConnectionStatus( - runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' + runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting", ), })), - [daemons, runtime, runtimeConnectionStatusSignature] - ) - const hostTriggerRef = useRef(null) - const [isHostPickerOpen, setIsHostPickerOpen] = useState(false) + [daemons, runtime, runtimeConnectionStatusSignature], + ); + const hostTriggerRef = useRef(null); + const [isHostPickerOpen, setIsHostPickerOpen] = useState(false); - const isOpen = isMobile ? mobileView === 'agent-list' : desktopAgentListOpen + const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen; const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ serverId: activeServerId, enabled: isOpen, - }) - const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } = - useSidebarShortcutModel(projects) + }); + const { + collapsedProjectKeys, + shortcutIndexByWorkspaceKey, + toggleProjectCollapsed, + setProjectCollapsed, + } = useSidebarShortcutModel(projects); - const [isManualRefresh, setIsManualRefresh] = useState(false) + const [isManualRefresh, setIsManualRefresh] = useState(false); const handleRefresh = useCallback(() => { - setIsManualRefresh(true) - refreshAll() - }, [refreshAll]) + setIsManualRefresh(true); + refreshAll(); + }, [refreshAll]); useEffect(() => { if (!isRevalidating && isManualRefresh) { - setIsManualRefresh(false) + setIsManualRefresh(false); } - }, [isRevalidating, isManualRefresh]) + }, [isRevalidating, isManualRefresh]); - const openProjectPicker = useOpenProjectPicker(activeServerId) + const openProjectPicker = useOpenProjectPicker(activeServerId); const handleOpenProjectMobile = useCallback(() => { - closeToAgent() - void openProjectPicker() - }, [closeToAgent, openProjectPicker]) + closeToAgent(); + void openProjectPicker(); + }, [closeToAgent, openProjectPicker]); const handleOpenProjectDesktop = useCallback(() => { - void openProjectPicker() - }, [openProjectPicker]) + void openProjectPicker(); + }, [openProjectPicker]); const handleSettingsMobile = useCallback(() => { if (!activeServerId) { - return + return; } - closeToAgent() - router.push(buildHostSettingsRoute(activeServerId) as any) - }, [activeServerId, closeToAgent]) + closeToAgent(); + router.push(buildHostSettingsRoute(activeServerId) as any); + }, [activeServerId, closeToAgent]); const handleSettingsDesktop = useCallback(() => { if (!activeServerId) { - return + return; } - router.push(buildHostSettingsRoute(activeServerId) as any) - }, [activeServerId]) + router.push(buildHostSettingsRoute(activeServerId) as any); + }, [activeServerId]); const handleViewMoreNavigate = useCallback(() => { if (!activeServerId) { - return + return; } - router.push(buildHostSessionsRoute(activeServerId) as any) - }, [activeServerId]) + router.push(buildHostSessionsRoute(activeServerId) as any); + }, [activeServerId]); const handleHostSelect = useCallback( (nextServerId: string) => { if (!nextServerId) { - return + return; } - const nextPath = mapPathnameToServer(pathname, nextServerId) - setIsHostPickerOpen(false) - router.push(nextPath as any) + const nextPath = mapPathnameToServer(pathname, nextServerId); + setIsHostPickerOpen(false); + router.push(nextPath as any); }, - [pathname] - ) + [pathname], + ); const sharedProps = { theme, @@ -255,7 +264,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte setProjectCollapsed, handleRefresh, handleHostSelect, - } + }; if (isMobile) { return ( @@ -269,7 +278,7 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte handleSettings={handleSettingsMobile} handleViewMoreNavigate={handleViewMoreNavigate} /> - ) + ); } return ( @@ -280,13 +289,13 @@ export const LeftSidebar = memo(function LeftSidebar({ selectedAgentId: _selecte handleSettings={handleSettingsDesktop} handleViewMore={handleViewMoreNavigate} /> - ) -}) + ); +}); function SessionsButton({ onPress }: { onPress: () => void }) { - const { theme } = useUnistyles() - const pathname = usePathname() - const isActive = pathname.includes('/sessions') + const { theme } = useUnistyles(); + const pathname = usePathname(); + const isActive = pathname.includes("/sessions"); return ( void }) { color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted} /> Sessions )} - ) + ); } function MobileSidebar({ @@ -353,22 +365,22 @@ function MobileSidebar({ animateToClose, isGesturing, closeGestureRef, - } = useSidebarAnimation() - const closeTouchStartX = useSharedValue(0) - const closeTouchStartY = useSharedValue(0) + } = useSidebarAnimation(); + const closeTouchStartX = useSharedValue(0); + const closeTouchStartY = useSharedValue(0); const handleClose = useCallback(() => { - closeToAgent() - }, [closeToAgent]) + closeToAgent(); + }, [closeToAgent]); const handleViewMore = useCallback(() => { if (!activeServerId) { - return + return; } - translateX.value = -windowWidth - backdropOpacity.value = 0 - closeToAgent() - handleViewMoreNavigate() + translateX.value = -windowWidth; + backdropOpacity.value = 0; + closeToAgent(); + handleViewMoreNavigate(); }, [ activeServerId, backdropOpacity, @@ -376,7 +388,7 @@ function MobileSidebar({ handleViewMoreNavigate, translateX, windowWidth, - ]) + ]); const closeGesture = useMemo( () => @@ -385,62 +397,62 @@ function MobileSidebar({ .enabled(isOpen) .manualActivation(true) .onTouchesDown((event) => { - const touch = event.changedTouches[0] + const touch = event.changedTouches[0]; if (!touch) { - return + return; } - closeTouchStartX.value = touch.absoluteX - closeTouchStartY.value = touch.absoluteY + closeTouchStartX.value = touch.absoluteX; + closeTouchStartY.value = touch.absoluteY; }) .onTouchesMove((event, stateManager) => { - const touch = event.changedTouches[0] + const touch = event.changedTouches[0]; if (!touch || event.numberOfTouches !== 1) { - stateManager.fail() - return + stateManager.fail(); + return; } - const deltaX = touch.absoluteX - closeTouchStartX.value - const deltaY = touch.absoluteY - closeTouchStartY.value - const absDeltaX = Math.abs(deltaX) - const absDeltaY = Math.abs(deltaY) + const deltaX = touch.absoluteX - closeTouchStartX.value; + const deltaY = touch.absoluteY - closeTouchStartY.value; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); if (deltaX >= 10) { - stateManager.fail() - return + stateManager.fail(); + return; } if (absDeltaY > 10 && absDeltaY > absDeltaX) { - stateManager.fail() - return + stateManager.fail(); + return; } if (deltaX <= -15 && absDeltaX > absDeltaY) { - stateManager.activate() + stateManager.activate(); } }) .onStart(() => { - isGesturing.value = true + isGesturing.value = true; }) .onUpdate((event) => { - const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)) - translateX.value = newTranslateX + const newTranslateX = Math.min(0, Math.max(-windowWidth, event.translationX)); + translateX.value = newTranslateX; backdropOpacity.value = interpolate( newTranslateX, [-windowWidth, 0], [0, 1], - Extrapolation.CLAMP - ) + Extrapolation.CLAMP, + ); }) .onEnd((event) => { - isGesturing.value = false - const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500 + isGesturing.value = false; + const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; if (shouldClose) { - animateToClose() - runOnJS(handleClose)() + animateToClose(); + runOnJS(handleClose)(); } else { - animateToOpen() + animateToOpen(); } }) .onFinalize(() => { - isGesturing.value = false + isGesturing.value = false; }), [ isOpen, @@ -454,29 +466,29 @@ function MobileSidebar({ animateToClose, animateToOpen, handleClose, - ] - ) + ], + ); const mobileSidebarInsetStyle = useMemo( () => ({ width: windowWidth, paddingTop: insetsTop, paddingBottom: insetsBottom }), - [windowWidth, insetsTop, insetsBottom] - ) + [windowWidth, insetsTop, insetsBottom], + ); const hostStatusDotStyle = useMemo( () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], - [activeHostStatusColor] - ) + [activeHostStatusColor], + ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], - })) + })); const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value, - pointerEvents: backdropOpacity.value > 0.01 ? 'auto' : 'none', - })) + pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", + })); - const overlayPointerEvents = Platform.OS === 'web' ? (isOpen ? 'auto' : 'none') : 'box-none' + const overlayPointerEvents = Platform.OS === "web" ? (isOpen ? "auto" : "none") : "box-none"; return ( @@ -554,7 +566,7 @@ function MobileSidebar({ Add project - + @@ -578,7 +590,7 @@ function MobileSidebar({ - ) + ); } function DesktopSidebar({ @@ -619,15 +631,15 @@ function DesktopSidebar({ isOpen, handleViewMore, }: DesktopSidebarProps) { - const dragHandlers = useDesktopDragHandlers() - const trafficLightPadding = useTrafficLightPadding() + const dragHandlers = useDesktopDragHandlers(); + const trafficLightPadding = useTrafficLightPadding(); const hostStatusDotStyle = useMemo( () => [styles.hostStatusDot, { backgroundColor: activeHostStatusColor }], - [activeHostStatusColor] - ) + [activeHostStatusColor], + ); if (!isOpen) { - return null + return null; } return ( @@ -697,7 +709,7 @@ function DesktopSidebar({ Add project - + @@ -721,7 +733,7 @@ function DesktopSidebar({ - ) + ); } const styles = StyleSheet.create((theme) => ({ backdrop: { ...StyleSheet.absoluteFillObject, - backgroundColor: 'rgba(0, 0, 0, 0.5)', + backgroundColor: "rgba(0, 0, 0, 0.5)", }, backdropPressable: { flex: 1, }, mobileSidebar: { - position: 'absolute', + position: "absolute", top: 0, left: 0, bottom: 0, backgroundColor: theme.colors.surfaceSidebar, - overflow: 'hidden', + overflow: "hidden", }, sidebarContent: { flex: 1, minHeight: 0, - overflow: 'hidden', + overflow: "hidden", }, desktopSidebar: { borderRightWidth: 1, @@ -767,21 +779,21 @@ const styles = StyleSheet.create((theme) => ({ md: HEADER_INNER_HEIGHT, }, paddingHorizontal: theme.spacing[2], - justifyContent: 'center', + justifyContent: "center", borderBottomWidth: 1, borderBottomColor: theme.colors.border, - userSelect: 'none', + userSelect: "none", }, sidebarHeaderRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", gap: theme.spacing[2], }, newAgentButton: { flex: 1, - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], @@ -802,9 +814,9 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface1, }, hostTrigger: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'flex-start', + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-start", gap: theme.spacing[2], minWidth: 0, paddingVertical: theme.spacing[1], @@ -826,9 +838,9 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, }, sidebarFooter: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[3], borderTopWidth: 1, @@ -841,16 +853,16 @@ const styles = StyleSheet.create((theme) => ({ marginRight: theme.spacing[2], }, footerIconRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], flexShrink: 0, }, footerIconButton: { width: 28, height: 28, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], }, @@ -876,19 +888,19 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface0, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, - alignItems: 'center', + alignItems: "center", }, hostPickerCancelText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, tooltipRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, -})) +})); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 7ea9d1a4a..261da6465 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -11,172 +11,165 @@ import { Image, Platform, BackHandler, -} from 'react-native' -import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react' -import { StyleSheet, useUnistyles } from 'react-native-unistyles' -import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from 'lucide-react-native' -import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated' -import { useDictation } from '@/hooks/use-dictation' -import { DictationOverlay } from './dictation-controls' -import { RealtimeVoiceOverlay } from './realtime-voice-overlay' -import type { DaemonClient } from '@server/client/daemon-client' -import { useSessionStore } from '@/stores/session-store' -import { useVoiceOptional } from '@/contexts/voice-context' -import { useToast } from '@/contexts/toast-context' -import { resolveVoiceUnavailableMessage } from '@/utils/server-info-capabilities' +} from "react-native"; +import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from "react"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native"; +import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; +import { useDictation } from "@/hooks/use-dictation"; +import { DictationOverlay } from "./dictation-controls"; +import { RealtimeVoiceOverlay } from "./realtime-voice-overlay"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { useSessionStore } from "@/stores/session-store"; +import { useVoiceOptional } from "@/contexts/voice-context"; +import { useToast } from "@/contexts/toast-context"; +import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; import { collectImageFilesFromClipboardData, filesToImageAttachments, -} from '@/utils/image-attachments-from-files' -import type { AttachmentMetadata } from '@/attachments/types' -import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url' -import { focusWithRetries } from '@/utils/web-focus' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { Shortcut } from '@/components/ui/shortcut' -import type { MessageInputKeyboardActionKind } from '@/keyboard/actions' +} from "@/utils/image-attachments-from-files"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; +import { focusWithRetries } from "@/utils/web-focus"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Shortcut } from "@/components/ui/shortcut"; +import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { markScrollInvestigationEvent, markScrollInvestigationRender, -} from '@/utils/scroll-jank-investigation' +} from "@/utils/scroll-jank-investigation"; -export type ImageAttachment = AttachmentMetadata +export type ImageAttachment = AttachmentMetadata; export interface MessagePayload { - text: string - images?: ImageAttachment[] + text: string; + images?: ImageAttachment[]; /** When true, bypasses queue and sends immediately even if agent is running */ - forceSend?: boolean + forceSend?: boolean; } export interface MessageInputProps { - value: string - onChangeText: (text: string) => void - onSubmit: (payload: MessagePayload) => void - isSubmitDisabled?: boolean - isSubmitLoading?: boolean - images?: ImageAttachment[] - onPickImages?: () => void - onAddImages?: (images: ImageAttachment[]) => void - onRemoveImage?: (index: number) => void - client: DaemonClient | null + value: string; + onChangeText: (text: string) => void; + onSubmit: (payload: MessagePayload) => void; + isSubmitDisabled?: boolean; + isSubmitLoading?: boolean; + images?: ImageAttachment[]; + onPickImages?: () => void; + onAddImages?: (images: ImageAttachment[]) => void; + onRemoveImage?: (index: number) => void; + client: DaemonClient | null; /** Dictation start gate from host runtime (socket connected + directory ready). */ - isReadyForDictation?: boolean - placeholder?: string - autoFocus?: boolean - autoFocusKey?: string - disabled?: boolean + isReadyForDictation?: boolean; + placeholder?: string; + autoFocus?: boolean; + autoFocusKey?: string; + disabled?: boolean; /** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ - isScreenFocused?: boolean + isScreenFocused?: boolean; /** Content to render on the left side of the button row (e.g., AgentStatusBar) */ - leftContent?: React.ReactNode + leftContent?: React.ReactNode; /** Content to render on the right side after voice button (e.g., realtime button, cancel button) */ - rightContent?: React.ReactNode - voiceServerId?: string - voiceAgentId?: string + rightContent?: React.ReactNode; + voiceServerId?: string; + voiceAgentId?: string; /** When true and there's sendable content, calls onQueue instead of onSubmit */ - isAgentRunning?: boolean + isAgentRunning?: boolean; /** Callback for queue button when agent is running */ - onQueue?: (payload: MessagePayload) => void + onQueue?: (payload: MessagePayload) => void; /** Optional handler used when submit button is in loading state. */ - onSubmitLoadingPress?: () => void + onSubmitLoadingPress?: () => void; /** Intercept key press events before default handling. Return true to prevent default. */ - onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean + onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean; /** Reports cursor selection updates from the underlying input. */ - onSelectionChange?: (selection: { start: number; end: number }) => void - onFocusChange?: (focused: boolean) => void - onHeightChange?: (height: number) => void + onSelectionChange?: (selection: { start: number; end: number }) => void; + onFocusChange?: (focused: boolean) => void; + onHeightChange?: (height: number) => void; } export interface MessageInputRef { - focus: () => void - blur: () => void - runKeyboardAction: (action: MessageInputKeyboardActionKind) => void + focus: () => void; + blur: () => void; + runKeyboardAction: (action: MessageInputKeyboardActionKind) => void; /** * Web-only: return the underlying DOM element for focus assertions/retries. * May return null if not mounted or on native. */ - getNativeElement?: () => HTMLElement | null + getNativeElement?: () => HTMLElement | null; } -const MIN_INPUT_HEIGHT = 30 -const MAX_INPUT_HEIGHT = 160 -const IS_WEB = Platform.OS === 'web' +const MIN_INPUT_HEIGHT = 30; +const MAX_INPUT_HEIGHT = 160; +const IS_WEB = Platform.OS === "web"; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { - metaKey?: boolean - ctrlKey?: boolean - shiftKey?: boolean + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; } -> +>; type TextAreaHandle = { - scrollHeight?: number - clientHeight?: number - offsetHeight?: number - scrollTop?: number - selectionStart?: number | null - selectionEnd?: number | null + scrollHeight?: number; + clientHeight?: number; + offsetHeight?: number; + scrollTop?: number; + selectionStart?: number | null; + selectionEnd?: number | null; style?: { - height?: string - overflowY?: string - } & Record -} + height?: string; + overflowY?: string; + } & Record; +}; -function logWebStickyBottom( - _event: string, - _details: Record -): void { +function logWebStickyBottom(_event: string, _details: Record): void { // Intentionally disabled: this path is too noisy during voice debugging. } function getDebugNow(): number | null { - if (typeof performance !== 'undefined' && typeof performance.now === 'function') { - return Number(performance.now().toFixed(3)) + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return Number(performance.now().toFixed(3)); } - return null + return null; } function getElementDescriptor(element: HTMLElement | null): string | null { - if (!element) return null - const tag = element.tagName?.toLowerCase() ?? 'unknown' - const id = element.id ? `#${element.id}` : '' - const testId = element.getAttribute?.('data-testid') - const label = element.getAttribute?.('aria-label') - const suffix = testId - ? `[data-testid="${testId}"]` - : label - ? `[aria-label="${label}"]` - : '' - return `${tag}${id}${suffix}` + if (!element) return null; + const tag = element.tagName?.toLowerCase() ?? "unknown"; + const id = element.id ? `#${element.id}` : ""; + const testId = element.getAttribute?.("data-testid"); + const label = element.getAttribute?.("aria-label"); + const suffix = testId ? `[data-testid="${testId}"]` : label ? `[aria-label="${label}"]` : ""; + return `${tag}${id}${suffix}`; } function getScrollableAncestorChain(element: HTMLElement | null): string[] { - if (!element || typeof window === 'undefined') { - return [] + if (!element || typeof window === "undefined") { + return []; } - const results: string[] = [] - let current = element.parentElement + const results: string[] = []; + let current = element.parentElement; while (current) { - const style = window.getComputedStyle(current) - const overflowY = style.overflowY + const style = window.getComputedStyle(current); + const overflowY = style.overflowY; const canScroll = - (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') && - current.scrollHeight > current.clientHeight + (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") && + current.scrollHeight > current.clientHeight; if (canScroll) { - results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase()) + results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase()); } - current = current.parentElement + current = current.parentElement; } - return results + return results; } function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { - const uri = useAttachmentPreviewUrl(image) + const uri = useAttachmentPreviewUrl(image); if (!uri) { - return + return ; } - return + return ; } export const MessageInput = forwardRef(function MessageInput( @@ -192,7 +185,7 @@ export const MessageInput = forwardRef(funct onRemoveImage, client, isReadyForDictation, - placeholder = 'Message...', + placeholder = "Message...", autoFocus = false, autoFocusKey, disabled = false, @@ -209,163 +202,166 @@ export const MessageInput = forwardRef(funct onFocusChange, onHeightChange, }, - ref + ref, ) { - const { theme } = useUnistyles() - const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg - const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}` - markScrollInvestigationRender(investigationComponentId) - const toast = useToast() - const voice = useVoiceOptional() - const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT) - const rootRef = useRef(null) - const inputWrapperRef = useRef(null) + const { theme } = useUnistyles(); + const buttonIconSize = IS_WEB ? theme.iconSize.md : theme.iconSize.lg; + const investigationComponentId = `MessageInput:${voiceServerId ?? "unknown-server"}:${voiceAgentId ?? "unknown-agent"}`; + markScrollInvestigationRender(investigationComponentId); + const toast = useToast(); + const voice = useVoiceOptional(); + const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); + const rootRef = useRef(null); + const inputWrapperRef = useRef(null); const textInputRef = useRef unknown }) | null>( - null - ) - const isInputFocusedRef = useRef(false) + null, + ); + const isInputFocusedRef = useRef(false); useImperativeHandle(ref, () => ({ focus: () => { - textInputRef.current?.focus() + textInputRef.current?.focus(); }, blur: () => { - textInputRef.current?.blur?.() + textInputRef.current?.blur?.(); }, runKeyboardAction: (action) => { - if (action === 'focus') { - textInputRef.current?.focus() - return + if (action === "focus") { + textInputRef.current?.focus(); + return; } - if (action === 'voice-toggle') { - handleToggleRealtimeVoiceShortcut() - return + if (action === "voice-toggle") { + handleToggleRealtimeVoiceShortcut(); + return; } - if (action === 'voice-mute-toggle') { + if (action === "voice-mute-toggle") { if (isRealtimeVoiceForCurrentAgent) { - voice?.toggleMute() + voice?.toggleMute(); } - return + return; } - if (action === 'dictation-cancel') { + if (action === "dictation-cancel") { if (isDictatingRef.current) { - cancelDictation() + cancelDictation(); } - return + return; } - if (action === 'dictation-toggle') { + if (action === "dictation-toggle") { if (isDictatingRef.current) { - sendAfterTranscriptRef.current = true - confirmDictation() + sendAfterTranscriptRef.current = true; + confirmDictation(); } else { - void startDictationIfAvailable() + void startDictationIfAvailable(); } } }, getNativeElement: () => { - if (!IS_WEB) return null - const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null - const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current - return native instanceof HTMLElement ? native : null + if (!IS_WEB) return null; + const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null; + const native = typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; + return native instanceof HTMLElement ? native : null; }, - })) - const inputHeightRef = useRef(MIN_INPUT_HEIGHT) - const baselineInputHeightRef = useRef(null) - const overlayTransition = useSharedValue(0) - const sendAfterTranscriptRef = useRef(false) - const valueRef = useRef(value) + })); + const inputHeightRef = useRef(MIN_INPUT_HEIGHT); + const baselineInputHeightRef = useRef(null); + const overlayTransition = useSharedValue(0); + const sendAfterTranscriptRef = useRef(false); + const valueRef = useRef(value); const serverInfo = useSessionStore( useCallback( (state) => { if (!voiceServerId) { - return null + return null; } - return state.sessions[voiceServerId]?.serverInfo ?? null + return state.sessions[voiceServerId]?.serverInfo ?? null; }, - [voiceServerId] - ) - ) + [voiceServerId], + ), + ); useEffect(() => { - valueRef.current = value - }, [value]) + valueRef.current = value; + }, [value]); useEffect(() => { return () => { - onFocusChange?.(false) - } - }, [onFocusChange]) + onFocusChange?.(false); + }; + }, [onFocusChange]); // Autofocus on web when autoFocus is true, and re-run when focus key changes. useEffect(() => { - if (!IS_WEB || !autoFocus) return + if (!IS_WEB || !autoFocus) return; return focusWithRetries({ focus: () => textInputRef.current?.focus(), isFocused: () => { - const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null - const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current - const element = native instanceof HTMLElement ? native : null - const active = typeof document !== 'undefined' ? document.activeElement : null - return Boolean(element) && active === element + const current = textInputRef.current as + | (TextInput & { getNativeRef?: () => unknown }) + | null; + const native = + typeof current?.getNativeRef === "function" ? current.getNativeRef() : current; + const element = native instanceof HTMLElement ? native : null; + const active = typeof document !== "undefined" ? document.activeElement : null; + return Boolean(element) && active === element; }, - }) - }, [autoFocus, autoFocusKey]) + }); + }, [autoFocus, autoFocusKey]); const handleDictationTranscript = useCallback( (text: string, _meta: { requestId: string }) => { - if (!text) return - const current = valueRef.current - const shouldPad = current.length > 0 && !/\s$/.test(current) - const nextValue = `${current}${shouldPad ? ' ' : ''}${text}` + if (!text) return; + const current = valueRef.current; + const shouldPad = current.length > 0 && !/\s$/.test(current); + const nextValue = `${current}${shouldPad ? " " : ""}${text}`; - const shouldAutoSend = sendAfterTranscriptRef.current - sendAfterTranscriptRef.current = false + const shouldAutoSend = sendAfterTranscriptRef.current; + sendAfterTranscriptRef.current = false; if (shouldAutoSend) { - const imageAttachments = images.length > 0 ? images : undefined + const imageAttachments = images.length > 0 ? images : undefined; onSubmit({ text: nextValue, images: imageAttachments, forceSend: isAgentRunning || undefined, - }) + }); } else { - onChangeText(nextValue) + onChangeText(nextValue); } - if (IS_WEB && typeof requestAnimationFrame === 'function') { + if (IS_WEB && typeof requestAnimationFrame === "function") { requestAnimationFrame(() => { - measureWebInputHeight('dictation') - }) + measureWebInputHeight("dictation"); + }); } }, - [onChangeText, onSubmit, images, isAgentRunning] - ) + [onChangeText, onSubmit, images, isAgentRunning], + ); const handleDictationError = useCallback((error: Error) => { - console.error('[MessageInput] Dictation error:', error) - }, []) + console.error("[MessageInput] Dictation error:", error); + }, []); const dictationUnavailableMessage = resolveVoiceUnavailableMessage({ serverInfo, - mode: 'dictation', - }) + mode: "dictation", + }); const canStartDictation = useCallback(() => { - const socketConnected = client?.isConnected ?? false - const readyForDictation = isReadyForDictation ?? socketConnected - return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage - }, [client, disabled, dictationUnavailableMessage, isReadyForDictation]) + const socketConnected = client?.isConnected ?? false; + const readyForDictation = isReadyForDictation ?? socketConnected; + return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage; + }, [client, disabled, dictationUnavailableMessage, isReadyForDictation]); const canConfirmDictation = useCallback(() => { - const socketConnected = client?.isConnected ?? false - return socketConnected - }, [client]) - const isConnected = client?.isConnected ?? false - const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled + const socketConnected = client?.isConnected ?? false; + return socketConnected; + }, [client]); + const isConnected = client?.isConnected ?? false; + const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled; const { isRecording: isDictating, @@ -388,68 +384,67 @@ export const MessageInput = forwardRef(funct canConfirm: canConfirmDictation, autoStopWhenHidden: { isVisible: isScreenFocused }, enableDuration: true, - }) + }); - const isDictatingRef = useRef(isDictating) + const isDictatingRef = useRef(isDictating); useEffect(() => { - isDictatingRef.current = isDictating - }, [isDictating]) + isDictatingRef.current = isDictating; + }, [isDictating]); const isRealtimeVoiceForCurrentAgent = !!voice && !!voiceServerId && !!voiceAgentId && - voice.isVoiceModeForAgent(voiceServerId, voiceAgentId) - const showDictationOverlay = isDictating || isDictationProcessing || dictationStatus === 'failed' - const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent - const showOverlay = showDictationOverlay || showRealtimeOverlay - + voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); + const showDictationOverlay = isDictating || isDictationProcessing || dictationStatus === "failed"; + const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; + const showOverlay = showDictationOverlay || showRealtimeOverlay; useEffect(() => { if (isDictating || isDictationProcessing) { - return + return; } - sendAfterTranscriptRef.current = false - }, [dictationStatus, isDictating, isDictationProcessing]) + sendAfterTranscriptRef.current = false; + }, [dictationStatus, isDictating, isDictationProcessing]); const startDictationIfAvailable = useCallback(async () => { if (dictationUnavailableMessage) { - isDictatingRef.current = false - toast.error(dictationUnavailableMessage) - return + isDictatingRef.current = false; + toast.error(dictationUnavailableMessage); + return; } // Keep hotkey toggling deterministic between the async start call and the // state-ref sync effect, so a rapid second toggle routes to confirm. - isDictatingRef.current = true - await startDictation() - }, [dictationUnavailableMessage, startDictation, toast]) + isDictatingRef.current = true; + await startDictation(); + }, [dictationUnavailableMessage, startDictation, toast]); // Animate overlay useEffect(() => { overlayTransition.value = withTiming(showOverlay ? 1 : 0, { duration: 200, - }) - }, [overlayTransition, showOverlay]) + }); + }, [overlayTransition, showOverlay]); const overlayAnimatedStyle = useAnimatedStyle(() => ({ opacity: overlayTransition.value, - pointerEvents: overlayTransition.value > 0.5 ? 'auto' : 'none', - })) + pointerEvents: overlayTransition.value > 0.5 ? "auto" : "none", + })); const inputAnimatedStyle = useAnimatedStyle(() => ({ opacity: 1 - overlayTransition.value, - })) + })); const handleVoicePress = useCallback(async () => { if (isRealtimeVoiceForCurrentAgent && voice) { - voice.toggleMute() - return + voice.toggleMute(); + return; } if (isDictating) { - await cancelDictation() + await cancelDictation(); } else { - await startDictationIfAvailable() + await startDictationIfAvailable(); } }, [ cancelDictation, @@ -457,165 +452,169 @@ export const MessageInput = forwardRef(funct isRealtimeVoiceForCurrentAgent, startDictationIfAvailable, voice, - ]) + ]); const handleCancelRecording = useCallback(async () => { - await cancelDictation() - }, [cancelDictation]) + await cancelDictation(); + }, [cancelDictation]); const handleAcceptRecording = useCallback(async () => { - sendAfterTranscriptRef.current = false - await confirmDictation() - }, [confirmDictation]) + sendAfterTranscriptRef.current = false; + await confirmDictation(); + }, [confirmDictation]); const handleAcceptAndSendRecording = useCallback(async () => { - sendAfterTranscriptRef.current = true - await confirmDictation() - }, [confirmDictation]) + sendAfterTranscriptRef.current = true; + await confirmDictation(); + }, [confirmDictation]); const handleRetryFailedRecording = useCallback(() => { - void retryFailedDictation() - }, [retryFailedDictation]) + void retryFailedDictation(); + }, [retryFailedDictation]); const handleDiscardFailedRecording = useCallback(() => { - discardFailedDictation() - }, [discardFailedDictation]) + discardFailedDictation(); + }, [discardFailedDictation]); const handleStopRealtimeVoice = useCallback(async () => { if (!voice || !isRealtimeVoiceForCurrentAgent) { - return + return; } - const tasks: Promise[] = [] + const tasks: Promise[] = []; if (isAgentRunning && client && voiceAgentId) { - tasks.push(client.cancelAgent(voiceAgentId)) + tasks.push(client.cancelAgent(voiceAgentId)); } - tasks.push(voice.stopVoice()) + tasks.push(voice.stopVoice()); - const results = await Promise.allSettled(tasks) + const results = await Promise.allSettled(tasks); results.forEach((result) => { - if (result.status === 'rejected') { - console.error('[MessageInput] Failed to stop realtime voice', result.reason) + if (result.status === "rejected") { + console.error("[MessageInput] Failed to stop realtime voice", result.reason); } - }) - }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]) + }); + }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId]); const handleToggleRealtimeVoiceShortcut = useCallback(() => { if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) { - return + return; } if (voice.isVoiceSwitching) { - return + return; } if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) { - void handleStopRealtimeVoice() - return + void handleStopRealtimeVoice(); + return; } void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => { - console.error('[MessageInput] Failed to start realtime voice', error) + console.error("[MessageInput] Failed to start realtime voice", error); const message = - error instanceof Error ? error.message : typeof error === 'string' ? error : null + error instanceof Error ? error.message : typeof error === "string" ? error : null; if (message && message.trim().length > 0) { - toast.error(message) + toast.error(message); } - }) - }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]) + }); + }, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId]); const handleSendMessage = useCallback(() => { - const trimmed = value.trim() - if (!trimmed && images.length === 0) return + const trimmed = value.trim(); + if (!trimmed && images.length === 0) return; const payload = { text: trimmed, images: images.length > 0 ? images : undefined, forceSend: isAgentRunning || undefined, - } - onSubmit(payload) - inputHeightRef.current = MIN_INPUT_HEIGHT - setInputHeight(MIN_INPUT_HEIGHT) - onHeightChange?.(MIN_INPUT_HEIGHT) - }, [value, images, onSubmit, isAgentRunning, onHeightChange]) + }; + onSubmit(payload); + inputHeightRef.current = MIN_INPUT_HEIGHT; + setInputHeight(MIN_INPUT_HEIGHT); + onHeightChange?.(MIN_INPUT_HEIGHT); + }, [value, images, onSubmit, isAgentRunning, onHeightChange]); const handleQueueMessage = useCallback(() => { - if (!onQueue) return - const trimmed = value.trim() - if (!trimmed && images.length === 0) return + if (!onQueue) return; + const trimmed = value.trim(); + if (!trimmed && images.length === 0) return; const payload = { text: trimmed, images: images.length > 0 ? images : undefined, - } - onQueue(payload) - onChangeText('') - inputHeightRef.current = MIN_INPUT_HEIGHT - setInputHeight(MIN_INPUT_HEIGHT) - onHeightChange?.(MIN_INPUT_HEIGHT) - }, [value, images, onQueue, onChangeText, onHeightChange]) + }; + onQueue(payload); + onChangeText(""); + inputHeightRef.current = MIN_INPUT_HEIGHT; + setInputHeight(MIN_INPUT_HEIGHT); + onHeightChange?.(MIN_INPUT_HEIGHT); + }, [value, images, onQueue, onChangeText, onHeightChange]); // Web input height measurement function isTextAreaLike(v: unknown): v is TextAreaHandle { - return typeof v === 'object' && v !== null && 'scrollHeight' in v + return typeof v === "object" && v !== null && "scrollHeight" in v; } const getWebTextArea = useCallback((): TextAreaHandle | null => { - const ref = textInputRef.current - if (!ref) return null - if (typeof (ref as any).getNativeRef === 'function') { - const native = (ref as any).getNativeRef() - if (isTextAreaLike(native)) return native + const ref = textInputRef.current; + if (!ref) return null; + if (typeof (ref as any).getNativeRef === "function") { + const native = (ref as any).getNativeRef(); + if (isTextAreaLike(native)) return native; } - if (isTextAreaLike(ref)) return ref - return null - }, []) + if (isTextAreaLike(ref)) return ref; + return null; + }, []); - const getWebElement = useCallback((target: 'root' | 'wrapper'): HTMLElement | null => { - const ref = target === 'root' ? rootRef.current : inputWrapperRef.current - if (!ref) return null - return ref instanceof HTMLElement ? ref : ((ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect ? (ref as unknown as HTMLElement) : null) - }, []) + const getWebElement = useCallback((target: "root" | "wrapper"): HTMLElement | null => { + const ref = target === "root" ? rootRef.current : inputWrapperRef.current; + if (!ref) return null; + return ref instanceof HTMLElement + ? ref + : (ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect + ? (ref as unknown as HTMLElement) + : null; + }, []); useEffect(() => { if (!IS_WEB || !onAddImages) { - return + return; } - const textarea = getWebTextArea() + const textarea = getWebTextArea(); if ( !textarea || - typeof (textarea as any).addEventListener !== 'function' || - typeof (textarea as any).removeEventListener !== 'function' + typeof (textarea as any).addEventListener !== "function" || + typeof (textarea as any).removeEventListener !== "function" ) { - return + return; } - let disposed = false + let disposed = false; const handlePaste = (event: ClipboardEvent) => { if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) { - return + return; } - const imageFiles = collectImageFilesFromClipboardData(event.clipboardData) + const imageFiles = collectImageFilesFromClipboardData(event.clipboardData); if (imageFiles.length === 0) { - return + return; } - event.preventDefault() + event.preventDefault(); void filesToImageAttachments(imageFiles) .then((attachments) => { if (disposed || attachments.length === 0) { - return + return; } - onAddImages(attachments) + onAddImages(attachments); }) .catch((error) => { - console.error('[MessageInput] Failed to process pasted images:', error) - }) - } + console.error("[MessageInput] Failed to process pasted images:", error); + }); + }; - ;(textarea as any).addEventListener('paste', handlePaste) + (textarea as any).addEventListener("paste", handlePaste); return () => { - disposed = true - ;(textarea as any).removeEventListener('paste', handlePaste) - } + disposed = true; + (textarea as any).removeEventListener("paste", handlePaste); + }; }, [ disabled, getWebTextArea, @@ -623,35 +622,38 @@ export const MessageInput = forwardRef(funct isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, - ]) + ]); useEffect(() => { - if (!IS_WEB || typeof ResizeObserver === 'undefined') { - return + if (!IS_WEB || typeof ResizeObserver === "undefined") { + return; } - const textarea = getWebTextArea() - const root = getWebElement('root') - const wrapper = getWebElement('wrapper') + const textarea = getWebTextArea(); + const root = getWebElement("root"); + const wrapper = getWebElement("wrapper"); const observed = [ - { name: 'composer_root', element: root }, - { name: 'composer_wrapper', element: wrapper }, - { name: 'composer_textarea', element: textarea as unknown as HTMLElement | null }, - ].filter((entry): entry is { name: string; element: HTMLElement } => entry.element instanceof HTMLElement) + { name: "composer_root", element: root }, + { name: "composer_wrapper", element: wrapper }, + { name: "composer_textarea", element: textarea as unknown as HTMLElement | null }, + ].filter( + (entry): entry is { name: string; element: HTMLElement } => + entry.element instanceof HTMLElement, + ); if (observed.length === 0) { - return + return; } const observer = new ResizeObserver((entries) => { for (const entry of entries) { - const target = entry.target as HTMLElement - const match = observed.find((item) => item.element === target) + const target = entry.target as HTMLElement; + const match = observed.find((item) => item.element === target); if (!match) { - continue + continue; } - const textareaNode = getWebTextArea() - logWebStickyBottom('composer_element_resized', { + const textareaNode = getWebTextArea(); + logWebStickyBottom("composer_element_resized", { target: match.name, width: target.clientWidth, height: target.clientHeight, @@ -660,37 +662,38 @@ export const MessageInput = forwardRef(funct textareaClientHeight: textareaNode?.clientHeight ?? null, textareaOffsetHeight: textareaNode?.offsetHeight ?? null, textareaScrollHeight: textareaNode?.scrollHeight ?? null, - textareaScrollTop: (textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null, + textareaScrollTop: + (textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null, valueLength: valueRef.current.length, - }) + }); } - }) + }); for (const entry of observed) { - observer.observe(entry.element) + observer.observe(entry.element); } return () => { - observer.disconnect() - } - }, [getWebElement, getWebTextArea]) + observer.disconnect(); + }; + }, [getWebElement, getWebTextArea]); useEffect(() => { if (!IS_WEB) { - return + return; } - const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null - if (!textarea || typeof textarea.addEventListener !== 'function') { - return + const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null; + if (!textarea || typeof textarea.addEventListener !== "function") { + return; } const handleScroll = () => { - const textareaElement = textarea as unknown as HTMLElement + const textareaElement = textarea as unknown as HTMLElement; const chatScroller = - typeof document !== 'undefined' + typeof document !== "undefined" ? (document.querySelector('[data-testid="agent-chat-scroll"]') as HTMLElement | null) - : null - logWebStickyBottom('composer_textarea_scrolled', { + : null; + logWebStickyBottom("composer_textarea_scrolled", { now: getDebugNow(), scrollTop: textarea.scrollTop, clientHeight: textarea.clientHeight ?? null, @@ -699,42 +702,44 @@ export const MessageInput = forwardRef(funct selectionEnd: textarea.selectionEnd ?? null, textareaDescriptor: getElementDescriptor(textareaElement), chatScrollerDescriptor: getElementDescriptor(chatScroller), - chatScrollerContainsTextarea: Boolean(chatScroller && textareaElement && chatScroller.contains(textareaElement)), + chatScrollerContainsTextarea: Boolean( + chatScroller && textareaElement && chatScroller.contains(textareaElement), + ), textareaScrollableAncestors: getScrollableAncestorChain(textareaElement), valueLength: valueRef.current.length, - }) - } + }); + }; - textarea.addEventListener('scroll', handleScroll, { passive: true }) + textarea.addEventListener("scroll", handleScroll, { passive: true }); return () => { - textarea.removeEventListener('scroll', handleScroll) - } - }, [getWebTextArea]) + textarea.removeEventListener("scroll", handleScroll); + }; + }, [getWebTextArea]); function measureWebInputHeight(source: string): boolean { - if (!IS_WEB) return false - const textarea = getWebTextArea() - if (!textarea || typeof textarea.scrollHeight !== 'number') return false - const scrollHeight = textarea.scrollHeight ?? 0 + if (!IS_WEB) return false; + const textarea = getWebTextArea(); + if (!textarea || typeof textarea.scrollHeight !== "number") return false; + const scrollHeight = textarea.scrollHeight ?? 0; if (baselineInputHeightRef.current === null && scrollHeight > 0) { - baselineInputHeightRef.current = scrollHeight - logWebStickyBottom('composer_baseline_measured', { + baselineInputHeightRef.current = scrollHeight; + logWebStickyBottom("composer_baseline_measured", { source, baseline: scrollHeight, - }) + }); } - const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT - const rawTarget = scrollHeight > 0 ? scrollHeight : baseline - const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget)) + const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT; + const rawTarget = scrollHeight > 0 ? scrollHeight : baseline; + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget)); - const previousHeight = inputHeightRef.current + const previousHeight = inputHeightRef.current; if (Math.abs(previousHeight - bounded) >= 1) { - inputHeightRef.current = bounded - setInputHeight(bounded) - onHeightChange?.(bounded) - logWebStickyBottom('composer_height_changed', { + inputHeightRef.current = bounded; + setInputHeight(bounded); + onHeightChange?.(bounded); + logWebStickyBottom("composer_height_changed", { source, previousHeight, nextHeight: bounded, @@ -743,124 +748,124 @@ export const MessageInput = forwardRef(funct offsetHeight: textarea.offsetHeight ?? null, baseline, rawTarget, - }) - return true + }); + return true; } - return false + return false; } function setBoundedInputHeight(nextHeight: number) { - const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)) - if (Math.abs(inputHeightRef.current - bounded) < 1) return - const previousHeight = inputHeightRef.current - inputHeightRef.current = bounded - setInputHeight(bounded) - onHeightChange?.(bounded) - logWebStickyBottom('composer_height_changed_native', { + const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight)); + if (Math.abs(inputHeightRef.current - bounded) < 1) return; + const previousHeight = inputHeightRef.current; + inputHeightRef.current = bounded; + setInputHeight(bounded); + onHeightChange?.(bounded); + logWebStickyBottom("composer_height_changed_native", { previousHeight, nextHeight: bounded, - }) + }); } function handleContentSizeChange( - event: NativeSyntheticEvent + event: NativeSyntheticEvent, ) { - const contentHeight = event.nativeEvent.contentSize.height + const contentHeight = event.nativeEvent.contentSize.height; if (IS_WEB) { - logWebStickyBottom('composer_content_size_change', { + logWebStickyBottom("composer_content_size_change", { reportedHeight: contentHeight, - }) + }); if (baselineInputHeightRef.current === null && contentHeight > 0) { - baselineInputHeightRef.current = contentHeight - logWebStickyBottom('composer_baseline_measured', { - source: 'contentSizeChange', + baselineInputHeightRef.current = contentHeight; + logWebStickyBottom("composer_baseline_measured", { + source: "contentSizeChange", baseline: contentHeight, - }) + }); } - setBoundedInputHeight(contentHeight) - return + setBoundedInputHeight(contentHeight); + return; } - setBoundedInputHeight(contentHeight) + setBoundedInputHeight(contentHeight); } function handleSelectionChange(event: NativeSyntheticEvent) { - const start = event.nativeEvent.selection?.start ?? 0 - const end = event.nativeEvent.selection?.end ?? start + const start = event.nativeEvent.selection?.start ?? 0; + const end = event.nativeEvent.selection?.end ?? start; if (IS_WEB) { - const textarea = getWebTextArea() - logWebStickyBottom('composer_selection_changed', { + const textarea = getWebTextArea(); + logWebStickyBottom("composer_selection_changed", { now: getDebugNow(), start, end, textareaScrollTop: textarea?.scrollTop ?? null, textareaClientHeight: textarea?.clientHeight ?? null, textareaScrollHeight: textarea?.scrollHeight ?? null, - }) + }); } - onSelectionChangeCallback?.({ start, end }) + onSelectionChangeCallback?.({ start, end }); } - const shouldHandleDesktopSubmit = IS_WEB + const shouldHandleDesktopSubmit = IS_WEB; function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { - markScrollInvestigationEvent(investigationComponentId, 'keyPress') - if (!shouldHandleDesktopSubmit) return + markScrollInvestigationEvent(investigationComponentId, "keyPress"); + if (!shouldHandleDesktopSubmit) return; // Allow parent to intercept key events (e.g., for autocomplete navigation) if (onKeyPressCallback) { const handled = onKeyPressCallback({ key: event.nativeEvent.key, preventDefault: () => event.preventDefault(), - }) - if (handled) return + }); + if (handled) return; } - const { shiftKey, metaKey, ctrlKey } = event.nativeEvent + const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; - if (event.nativeEvent.key !== 'Enter') return + if (event.nativeEvent.key !== "Enter") return; // Shift+Enter: add newline (default behavior, don't intercept) - if (shiftKey) return + if (shiftKey) return; // Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running if ((metaKey || ctrlKey) && isAgentRunning && onQueue) { - if (isSubmitDisabled || isSubmitLoading || disabled) return - event.preventDefault() - handleQueueMessage() - return + if (isSubmitDisabled || isSubmitLoading || disabled) return; + event.preventDefault(); + handleQueueMessage(); + return; } // Enter: send (interrupts agent if running) - if (isSubmitDisabled || isSubmitLoading || disabled) return - event.preventDefault() - handleSendMessage() + if (isSubmitDisabled || isSubmitLoading || disabled) return; + event.preventDefault(); + handleSendMessage(); } - const hasImages = images.length > 0 - const hasSendableContent = value.trim().length > 0 || hasImages - const shouldShowSendButton = hasSendableContent || isSubmitLoading - const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === 'function' + const hasImages = images.length > 0; + const hasSendableContent = value.trim().length > 0 || hasImages; + const shouldShowSendButton = hasSendableContent || isSubmitLoading; + const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; const isSendButtonDisabled = - disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)) + disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading)); const submitAccessibilityLabel = canPressLoadingButton - ? 'Interrupt agent' + ? "Interrupt agent" : isAgentRunning - ? 'Send and interrupt' - : 'Send message' + ? "Send and interrupt" + : "Send message"; const handleInputChange = useCallback( (nextValue: string) => { - markScrollInvestigationEvent(investigationComponentId, 'inputChange') - onChangeText(nextValue) + markScrollInvestigationEvent(investigationComponentId, "inputChange"); + onChangeText(nextValue); if (IS_WEB) { - logWebStickyBottom('composer_text_changed', { + logWebStickyBottom("composer_text_changed", { valueLength: nextValue.length, - lineCount: nextValue.split('\n').length, - }) + lineCount: nextValue.split("\n").length, + }); } }, - [investigationComponentId, onChangeText] - ) + [investigationComponentId, onChangeText], + ); return ( @@ -905,12 +910,12 @@ export const MessageInput = forwardRef(funct placeholderTextColor={theme.colors.surface4} accessibilityLabel="Message agent..." onFocus={() => { - isInputFocusedRef.current = true - onFocusChange?.(true) + isInputFocusedRef.current = true; + onFocusChange?.(true); }} onBlur={() => { - isInputFocusedRef.current = false - onFocusChange?.(false) + isInputFocusedRef.current = false; + onFocusChange?.(false); }} style={[ styles.textInput, @@ -971,16 +976,16 @@ export const MessageInput = forwardRef(funct accessibilityLabel={ isRealtimeVoiceForCurrentAgent ? voice?.isMuted - ? 'Unmute Voice mode' - : 'Mute Voice mode' + ? "Unmute Voice mode" + : "Mute Voice mode" : isDictating - ? 'Stop dictation' - : 'Start dictation' + ? "Stop dictation" + : "Start dictation" } style={({ hovered }) => [ styles.voiceButton, hovered && !isDictating && styles.iconButtonHovered, - (!isDictationStartEnabled) && styles.buttonDisabled, + !isDictationStartEnabled && styles.buttonDisabled, isDictating && styles.voiceButtonRecording, ]} > @@ -997,12 +1002,12 @@ export const MessageInput = forwardRef(funct {isRealtimeVoiceForCurrentAgent ? voice?.isMuted - ? 'Unmute voice' - : 'Mute voice' - : 'Dictation'} + ? "Unmute voice" + : "Mute voice" + : "Dictation"} @@ -1027,7 +1032,7 @@ export const MessageInput = forwardRef(funct Queue - + @@ -1050,7 +1055,7 @@ export const MessageInput = forwardRef(funct Send - + @@ -1068,12 +1073,12 @@ export const MessageInput = forwardRef(funct isRecording={isDictating} isProcessing={isDictationProcessing} status={dictationStatus} - errorText={dictationStatus === 'failed' ? (dictationError ?? undefined) : undefined} + errorText={dictationStatus === "failed" ? (dictationError ?? undefined) : undefined} onCancel={handleCancelRecording} onAccept={handleAcceptRecording} onAcceptAndSend={handleAcceptAndSendRecording} - onRetry={dictationStatus === 'failed' ? handleRetryFailedRecording : undefined} - onDiscard={dictationStatus === 'failed' ? handleDiscardFailedRecording : undefined} + onRetry={dictationStatus === "failed" ? handleRetryFailedRecording : undefined} + onDiscard={dictationStatus === "failed" ? handleDiscardFailedRecording : undefined} /> ) : showRealtimeOverlay && voice ? ( (funct isSwitching={voice.isVoiceSwitching} onToggleMute={voice.toggleMute} onStop={() => { - void handleStopRealtimeVoice() + void handleStopRealtimeVoice(); }} /> ) : null} - ) -}) + ); +}); const styles = StyleSheet.create(((theme: any) => ({ container: { - position: 'relative', + position: "relative", }, inputWrapper: { - flexDirection: 'column', + flexDirection: "column", gap: theme.spacing[3], backgroundColor: theme.colors.surface1, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, - borderRadius: theme.borderRadius['2xl'], + borderRadius: theme.borderRadius["2xl"], paddingVertical: { xs: theme.spacing[2], md: theme.spacing[4], @@ -1111,26 +1116,26 @@ const styles = StyleSheet.create(((theme: any) => ({ }, ...(IS_WEB ? { - transitionProperty: 'border-color', - transitionDuration: '200ms', - transitionTimingFunction: 'ease-in-out', + transitionProperty: "border-color", + transitionDuration: "200ms", + transitionTimingFunction: "ease-in-out", } : {}), }, imagePreviewContainer: { - flexDirection: 'row', + flexDirection: "row", gap: theme.spacing[2], - flexWrap: 'wrap', + flexWrap: "wrap", }, imagePill: { - position: 'relative', + position: "relative", borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.borderAccent, - overflow: 'hidden', + overflow: "hidden", ...(IS_WEB ? { - cursor: 'pointer', + cursor: "pointer", } : {}), }, @@ -1144,19 +1149,19 @@ const styles = StyleSheet.create(((theme: any) => ({ backgroundColor: theme.colors.surface2, }, removeImageButton: { - position: 'absolute', + position: "absolute", top: 0, left: 0, right: 0, bottom: 0, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.5)', + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(0, 0, 0, 0.5)", opacity: 0, ...(IS_WEB ? { - transitionProperty: 'opacity', - transitionDuration: '150ms', + transitionProperty: "opacity", + transitionDuration: "150ms", } : {}), }, @@ -1164,47 +1169,47 @@ const styles = StyleSheet.create(((theme: any) => ({ opacity: 1, }, textInput: { - width: '100%', + width: "100%", color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.base * 1.4, ...(IS_WEB ? { - outlineStyle: 'none' as const, + outlineStyle: "none" as const, outlineWidth: 0, - outlineColor: 'transparent', + outlineColor: "transparent", } : {}), }, buttonRow: { - flexDirection: 'row', - alignItems: 'flex-end', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "flex-end", + justifyContent: "space-between", }, leftButtonGroup: { - flexDirection: 'row', - alignItems: 'flex-end', - gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1], + flexDirection: "row", + alignItems: "flex-end", + gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], }, rightButtonGroup: { - flexDirection: 'row', - alignItems: 'center', - gap: Platform.OS === 'web' ? theme.spacing[2] : theme.spacing[1], + flexDirection: "row", + alignItems: "center", + gap: Platform.OS === "web" ? theme.spacing[2] : theme.spacing[1], }, attachButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, voiceButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, voiceButtonRecording: { backgroundColor: theme.colors.destructive, @@ -1214,23 +1219,23 @@ const styles = StyleSheet.create(((theme: any) => ({ height: 28, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, sendButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, tooltipRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], }, tooltipText: { @@ -1245,15 +1250,15 @@ const styles = StyleSheet.create(((theme: any) => ({ opacity: 0.5, }, overlayContainer: { - position: 'absolute', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', + position: "absolute", + display: "flex", + alignItems: "center", + justifyContent: "center", top: 0, left: 0, - width: '100%', - height: '100%', + width: "100%", + height: "100%", right: 0, bottom: 0, }, -})) as any) as Record +})) as any) as Record; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 6d19bba52..93b5ee908 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -40,11 +40,7 @@ import { TriangleAlertIcon, Scissors, } from "lucide-react-native"; -import { - StyleSheet, - useUnistyles, - UnistylesRuntime, -} from "react-native-unistyles"; +import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import Animated, { Easing, cancelAnimation, @@ -53,23 +49,14 @@ import Animated, { withRepeat, withTiming, } from "react-native-reanimated"; -import Svg, { - Defs, - LinearGradient as SvgLinearGradient, - Rect, - Stop, -} from "react-native-svg"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { theme } from "@/styles/theme"; -import { - createMarkdownStyles, -} from "@/styles/markdown-styles"; +import { createMarkdownStyles } from "@/styles/markdown-styles"; import { Colors, Fonts } from "@/constants/theme"; import * as Clipboard from "expo-clipboard"; import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; -import { - buildToolCallDisplayModel, -} from "@/utils/tool-call-display"; +import { buildToolCallDisplayModel } from "@/utils/tool-call-display"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; import { hasMeaningfulToolCallDetail, @@ -159,10 +146,7 @@ function ensureWebToolCallShimmerKeyframes() { webToolCallShimmerRegistered = true; } -function getWheelEventElementTarget( - event: WheelEvent, - fallback: HTMLElement -): HTMLElement { +function getWheelEventElementTarget(event: WheelEvent, fallback: HTMLElement): HTMLElement { const { target } = event; if (target instanceof HTMLElement) { return target; @@ -176,7 +160,7 @@ function getWheelEventElementTarget( function canElementScrollInDirection( element: HTMLElement, axis: ScrollAxis, - delta: number + delta: number, ): boolean { if (delta === 0) { return false; @@ -192,7 +176,9 @@ function canElementScrollInDirection( const scrollPosition = axis === "x" ? element.scrollLeft : element.scrollTop; const scrollSize = - axis === "x" ? element.scrollWidth - element.clientWidth : element.scrollHeight - element.clientHeight; + axis === "x" + ? element.scrollWidth - element.clientWidth + : element.scrollHeight - element.clientHeight; if (scrollSize <= SCROLL_EDGE_EPSILON) { return false; } @@ -207,7 +193,7 @@ function canScrollInsideDetailFromTarget( detailRoot: HTMLElement, startElement: HTMLElement, axis: ScrollAxis, - delta: number + delta: number, ): boolean { if (delta === 0) { return false; @@ -226,14 +212,10 @@ function canScrollInsideDetailFromTarget( return false; } -function shouldStopDetailWheelPropagation( - detailRoot: HTMLElement, - event: WheelEvent -): boolean { +function shouldStopDetailWheelPropagation(detailRoot: HTMLElement, event: WheelEvent): boolean { const startElement = getWheelEventElementTarget(event, detailRoot); const verticalDelta = event.deltaY; - const horizontalDelta = - event.deltaX !== 0 ? event.deltaX : (event.shiftKey ? event.deltaY : 0); + const horizontalDelta = event.deltaX !== 0 ? event.deltaX : event.shiftKey ? event.deltaY : 0; const hasVerticalIntent = Math.abs(verticalDelta) > SCROLL_EDGE_EPSILON; const hasHorizontalIntent = Math.abs(horizontalDelta) > SCROLL_EDGE_EPSILON; @@ -245,12 +227,7 @@ function shouldStopDetailWheelPropagation( ? canScrollInsideDetailFromTarget(detailRoot, startElement, "y", verticalDelta) : false; const canScrollHorizontally = hasHorizontalIntent - ? canScrollInsideDetailFromTarget( - detailRoot, - startElement, - "x", - horizontalDelta - ) + ? canScrollInsideDetailFromTarget(detailRoot, startElement, "x", horizontalDelta) : false; if (hasVerticalIntent && hasHorizontalIntent) { @@ -336,11 +313,7 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ }, })); -function UserMessageAttachmentThumbnail({ - image, -}: { - image: UserMessageImageAttachment; -}) { +function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) { const uri = useAttachmentPreviewUrl(image); if (!uri) { return ; @@ -358,12 +331,10 @@ export const UserMessage = memo(function UserMessage({ }: UserMessageProps) { const [messageHovered, setMessageHovered] = useState(false); const [copyButtonHovered, setCopyButtonHovered] = useState(false); - const resolvedDisableOuterSpacing = - useDisableOuterSpacing(disableOuterSpacing); + const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const hasText = message.trim().length > 0; const hasImages = images.length > 0; - const showCopyButton = - hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); + const showCopyButton = hasText && (Platform.OS !== "web" || messageHovered || copyButtonHovered); return ( setMessageHovered(true) : undefined - } - onHoverOut={ - Platform.OS === "web" ? () => setMessageHovered(false) : undefined - } + onHoverIn={Platform.OS === "web" ? () => setMessageHovered(true) : undefined} + onHoverOut={Platform.OS === "web" ? () => setMessageHovered(false) : undefined} > {hasImages ? ( @@ -396,10 +361,7 @@ export const UserMessage = memo(function UserMessage({ ]} > {images.map((image, index) => ( - + ))} @@ -475,11 +437,7 @@ function MarkdownLink({ const [hovered, setHovered] = useState(false); if (Platform.OS !== "web") { return ( - onPress(href)} - style={style} - > + onPress(href)} style={style}> {children} ); @@ -492,25 +450,25 @@ function MarkdownLink({ onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} > - - {children} - + {children} ); } function getInlineCodeAutoLinkUrl( markdownParser: ReturnType, - content: string + content: string, ): string | null { const trimmed = content.trim(); if (!trimmed) { return null; } - const matches = markdownParser.linkify.match(trimmed) as - | Array<{ index: number; lastIndex: number; url: string }> - | null; + const matches = markdownParser.linkify.match(trimmed) as Array<{ + index: number; + lastIndex: number; + url: string; + }> | null; if (!matches || matches.length !== 1) { return null; } @@ -604,9 +562,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({ style={[turnCopyButtonStylesheet.container, containerStyle]} accessibilityRole="button" accessibilityLabel={ - copied - ? (copiedAccessibilityLabel ?? "Copied") - : (accessibilityLabel ?? "Copy turn") + copied ? (copiedAccessibilityLabel ?? "Copied") : (accessibilityLabel ?? "Copy turn") } > {({ hovered }) => { @@ -767,41 +723,38 @@ export const AssistantMessage = memo(function AssistantMessage({ }); const { theme, rt } = useUnistyles(); - const resolvedDisableOuterSpacing = - useDisableOuterSpacing(disableOuterSpacing); + const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]); - const markdownParser = useMemo( - () => { - const parser = MarkdownIt({ typographer: true, linkify: true }); - const defaultValidateLink = parser.validateLink.bind(parser); - parser.validateLink = (url: string) => { - if (url.trim().toLowerCase().startsWith("file://")) { - return true; - } + const markdownParser = useMemo(() => { + const parser = MarkdownIt({ typographer: true, linkify: true }); + const defaultValidateLink = parser.validateLink.bind(parser); + parser.validateLink = (url: string) => { + if (url.trim().toLowerCase().startsWith("file://")) { + return true; + } - return defaultValidateLink(url); - }; - return parser; - }, - [] - ); + return defaultValidateLink(url); + }; + return parser; + }, []); - const handleLinkPress = useCallback((url: string) => { - const fileTarget = onInlinePathPress - ? parseAssistantFileLink(url, { workspaceRoot }) - : null; - if (fileTarget) { - onInlinePathPress?.(fileTarget); + const handleLinkPress = useCallback( + (url: string) => { + const fileTarget = onInlinePathPress ? parseAssistantFileLink(url, { workspaceRoot }) : null; + if (fileTarget) { + onInlinePathPress?.(fileTarget); + return false; + } + + void openExternalUrl(url); + // react-native-markdown-display opens the link itself when this returns true. + // We already handled it above, so return false to avoid duplicate opens. return false; - } - - void openExternalUrl(url); - // react-native-markdown-display opens the link itself when this returns true. - // We already handled it above, so return false to avoid duplicate opens. - return false; - }, [onInlinePathPress, workspaceRoot]); + }, + [onInlinePathPress, workspaceRoot], + ); const markdownRules = useMemo(() => { return { @@ -810,7 +763,7 @@ export const AssistantMessage = memo(function AssistantMessage({ _children: ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( {node.content} @@ -821,12 +774,9 @@ export const AssistantMessage = memo(function AssistantMessage({ children: ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( - + {children} ), @@ -835,12 +785,9 @@ export const AssistantMessage = memo(function AssistantMessage({ _children: ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( - + {node.content} ), @@ -849,7 +796,7 @@ export const AssistantMessage = memo(function AssistantMessage({ _children: ReactNode[], _parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => ( {node.content} @@ -860,16 +807,14 @@ export const AssistantMessage = memo(function AssistantMessage({ _children: ReactNode[], parent: any, styles: any, - inheritedStyles: any = {} + inheritedStyles: any = {}, ) => { const content = node.content ?? ""; const isLinkedInlineCode = nodeHasParentType(parent, "link") || - (!Array.isArray(parent) && - typeof parent?.attributes?.href === "string"); - const parsed = onInlinePathPress && !isLinkedInlineCode - ? parseInlinePathToken(content) - : null; + (!Array.isArray(parent) && typeof parent?.attributes?.href === "string"); + const parsed = + onInlinePathPress && !isLinkedInlineCode ? parseInlinePathToken(content) : null; if (parsed) { return ( @@ -877,10 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({ key={node.key} onPress={() => parsed && onInlinePathPress?.(parsed)} selectable={false} - style={[ - assistantMessageStylesheet.pathChip, - assistantMessageStylesheet.pathChipText, - ]} + style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} > {content} @@ -902,81 +844,42 @@ export const AssistantMessage = memo(function AssistantMessage({ } return ( - + {content} ); }, - bullet_list: ( - node: any, - children: ReactNode[], - _parent: any, - styles: any - ) => ( + bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( {children} ), - ordered_list: ( - node: any, - children: ReactNode[], - _parent: any, - styles: any - ) => ( + ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => ( {children} ), - list_item: ( - node: any, - children: ReactNode[], - parent: any, - styles: any - ) => { + list_item: (node: any, children: ReactNode[], parent: any, styles: any) => { const { isOrdered, marker } = getMarkdownListMarker(node, parent); - const iconStyle = isOrdered - ? styles.ordered_list_icon - : styles.bullet_list_icon; - const contentStyle = isOrdered - ? styles.ordered_list_content - : styles.bullet_list_content; + const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; + const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; return ( {marker} - - {children} - + {children} ); }, - paragraph: ( - node: any, - children: ReactNode[], - parent: any, - styles: any, - ) => { + paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => { const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; return ( - + {children} ); }, - link: ( - node: any, - children: ReactNode[], - _parent: any, - styles: any, - ) => ( + link: (node: any, children: ReactNode[], _parent: any, styles: any) => ( {Children.map(children, (child) => isValidElement(child) - ? cloneElement(child, { style: [(child.props as any).style, { color: styles.link.color }] } as any) - : child + ? cloneElement(child, { + style: [(child.props as any).style, { color: styles.link.color }], + } as any) + : child, )} ), @@ -998,8 +903,7 @@ export const AssistantMessage = memo(function AssistantMessage({ testID="assistant-message" style={[ assistantMessageStylesheet.container, - !resolvedDisableOuterSpacing && - assistantMessageStylesheet.containerSpacing, + !resolvedDisableOuterSpacing && assistantMessageStylesheet.containerSpacing, ]} > - + {displayMessage} {metadata && ( @@ -1402,21 +1298,17 @@ const ExpandableBadge = memo(function ExpandableBadge({ testID, }: ExpandableBadgeProps) { const { theme } = useUnistyles(); - const resolvedDisableOuterSpacing = - useDisableOuterSpacing(disableOuterSpacing); + const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); const [isHovered, setIsHovered] = useState(false); const [isPressed, setIsPressed] = useState(false); const isInteractive = Boolean(onToggle); const hasDetailContent = Boolean(renderDetails); - const detailContent = - hasDetailContent && isExpanded ? renderDetails?.() : null; + const detailContent = hasDetailContent && isExpanded ? renderDetails?.() : null; const detailWrapperRef = useRef(null); - const wheelInvestigationComponentId = `ExpandableBadgeWheel:${ - testID ?? label - }`; + const wheelInvestigationComponentId = `ExpandableBadgeWheel:${testID ?? label}`; const nativeGradientIdRef = useRef( - `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}` + `shimmer-gradient-${Math.random().toString(36).substring(2, 9)}`, ); const [labelRowWidth, setLabelRowWidth] = useState(0); const [labelRowHeight, setLabelRowHeight] = useState(0); @@ -1426,77 +1318,64 @@ const ExpandableBadge = memo(function ExpandableBadge({ const [secondaryWidth, setSecondaryWidth] = useState(0); const shimmerTranslateX = useSharedValue(0); - const totalShimmerChars = - label.trim().length + (secondaryLabel?.trim().length ?? 0); + const totalShimmerChars = label.trim().length + (secondaryLabel?.trim().length ?? 0); const shortTextDurationAdjustment = totalShimmerChars <= 12 ? 0.25 : 0; const shimmerDuration = Math.max( 1, - Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment) + Math.min(2.3, 1.25 + totalShimmerChars * 0.008 - shortTextDurationAdjustment), ); const nativeShimmerPeakWidth = Math.max( 32, - Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0) + Math.min(120, labelRowWidth > 0 ? labelRowWidth * 0.28 : 0), ); const isWebShimmer = isLoading && Platform.OS === "web"; const shouldMeasureWebShimmer = isWebShimmer; const shouldMeasureNativeShimmer = isLoading && Platform.OS !== "web"; - const isNativeShimmer = - shouldMeasureNativeShimmer && - labelRowWidth > 0 && - labelRowHeight > 0; + const isNativeShimmer = shouldMeasureNativeShimmer && labelRowWidth > 0 && labelRowHeight > 0; const webShimmerSpanStartX = labelOffsetX; const webShimmerSpanEndX = secondaryLabel ? secondaryOffsetX + secondaryWidth : labelOffsetX + labelWidth; - const webShimmerSpanWidth = Math.max( - 1, - webShimmerSpanEndX - webShimmerSpanStartX - ); - const webShimmerPeakWidth = Math.max( - 42, - Math.min(120, webShimmerSpanWidth * 0.22) - ); + const webShimmerSpanWidth = Math.max(1, webShimmerSpanEndX - webShimmerSpanStartX); + const webShimmerPeakWidth = Math.max(42, Math.min(120, webShimmerSpanWidth * 0.22)); const webShimmerTrackStart = webShimmerSpanStartX - webShimmerPeakWidth; const webShimmerTrackEnd = webShimmerSpanEndX; - const handleLabelRowLayout = useCallback((event: LayoutChangeEvent) => { - if (!shouldMeasureNativeShimmer) { - return; - } - const { width, height } = event.nativeEvent.layout; - setLabelRowWidth((previous) => - Math.abs(previous - width) > 0.5 ? width : previous - ); - setLabelRowHeight((previous) => - Math.abs(previous - height) > 0.5 ? height : previous - ); - }, [shouldMeasureNativeShimmer]); + const handleLabelRowLayout = useCallback( + (event: LayoutChangeEvent) => { + if (!shouldMeasureNativeShimmer) { + return; + } + const { width, height } = event.nativeEvent.layout; + setLabelRowWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous)); + setLabelRowHeight((previous) => (Math.abs(previous - height) > 0.5 ? height : previous)); + }, + [shouldMeasureNativeShimmer], + ); - const handleLabelLayout = useCallback((event: LayoutChangeEvent) => { - if (!shouldMeasureWebShimmer) { - return; - } - const { x, width } = event.nativeEvent.layout; - setLabelOffsetX((previous) => - Math.abs(previous - x) > 0.5 ? x : previous - ); - setLabelWidth((previous) => - Math.abs(previous - width) > 0.5 ? width : previous - ); - }, [shouldMeasureWebShimmer]); + const handleLabelLayout = useCallback( + (event: LayoutChangeEvent) => { + if (!shouldMeasureWebShimmer) { + return; + } + const { x, width } = event.nativeEvent.layout; + setLabelOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous)); + setLabelWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous)); + }, + [shouldMeasureWebShimmer], + ); - const handleSecondaryLayout = useCallback((event: LayoutChangeEvent) => { - if (!shouldMeasureWebShimmer || !secondaryLabel) { - return; - } - const { x, width } = event.nativeEvent.layout; - setSecondaryOffsetX((previous) => - Math.abs(previous - x) > 0.5 ? x : previous - ); - setSecondaryWidth((previous) => - Math.abs(previous - width) > 0.5 ? width : previous - ); - }, [shouldMeasureWebShimmer, secondaryLabel]); + const handleSecondaryLayout = useCallback( + (event: LayoutChangeEvent) => { + if (!shouldMeasureWebShimmer || !secondaryLabel) { + return; + } + const { x, width } = event.nativeEvent.layout; + setSecondaryOffsetX((previous) => (Math.abs(previous - x) > 0.5 ? x : previous)); + setSecondaryWidth((previous) => (Math.abs(previous - width) > 0.5 ? width : previous)); + }, + [shouldMeasureWebShimmer, secondaryLabel], + ); useEffect(() => { if (!isWebShimmer) { @@ -1520,18 +1399,12 @@ const ExpandableBadge = memo(function ExpandableBadge({ easing: Easing.linear, }), -1, - false + false, ); return () => { cancelAnimation(shimmerTranslateX); }; - }, [ - isNativeShimmer, - labelRowWidth, - nativeShimmerPeakWidth, - shimmerDuration, - shimmerTranslateX, - ]); + }, [isNativeShimmer, labelRowWidth, nativeShimmerPeakWidth, shimmerDuration, shimmerTranslateX]); useEffect(() => { if (Platform.OS !== "web" || !isExpanded || !hasDetailContent) { @@ -1605,23 +1478,21 @@ const ExpandableBadge = memo(function ExpandableBadge({ : expandableBadgeStylesheet.containerSpacing), style, ], - [isLastInSequence, resolvedDisableOuterSpacing, style] + [isLastInSequence, resolvedDisableOuterSpacing, style], ); const pressableStyle = useMemo( () => [ expandableBadgeStylesheet.pressable, - isPressed && isInteractive - ? expandableBadgeStylesheet.pressablePressed - : null, + isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null, isExpanded && expandableBadgeStylesheet.pressableExpanded, ], - [isExpanded, isInteractive, isPressed] + [isExpanded, isInteractive, isPressed], ); const accessibilityState = useMemo( () => (isInteractive ? { expanded: isExpanded } : undefined), - [isExpanded, isInteractive] + [isExpanded, isInteractive], ); const isActive = isHovered || isExpanded; @@ -1632,7 +1503,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ isActive && expandableBadgeStylesheet.labelActive, isLoading && expandableBadgeStylesheet.labelLoading, ], - [isActive, isLoading] + [isActive, isLoading], ); const secondaryLabelStyle = useMemo( @@ -1640,7 +1511,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.secondaryLabel, isActive && expandableBadgeStylesheet.secondaryLabelActive, ], - [isActive] + [isActive], ); const shimmerLabelTextStyle = useMemo( @@ -1650,7 +1521,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.shimmerText, shimmerLabelStyle, ], - [isLoading, shimmerLabelStyle] + [isLoading, shimmerLabelStyle], ); const shimmerSecondaryTextStyle = useMemo( @@ -1659,7 +1530,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.shimmerText, shimmerSecondaryStyle, ], - [shimmerSecondaryStyle] + [shimmerSecondaryStyle], ); const nativeShimmerTrackStyle = useMemo( @@ -1667,7 +1538,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.nativeShimmerTrack, { width: labelRowWidth, height: labelRowHeight }, ], - [labelRowHeight, labelRowWidth] + [labelRowHeight, labelRowWidth], ); const nativeShimmerMaskStyle = useMemo( @@ -1675,20 +1546,17 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.shimmerMaskRow, { width: labelRowWidth, height: labelRowHeight }, ], - [labelRowHeight, labelRowWidth] + [labelRowHeight, labelRowWidth], ); const nativeLabelMaskStyle = useMemo( () => [expandableBadgeStylesheet.label, { color: "#000000", opacity: 1 }], - [] + [], ); const nativeSecondaryMaskStyle = useMemo( - () => [ - expandableBadgeStylesheet.secondaryLabel, - { color: "#000000", opacity: 1 }, - ], - [] + () => [expandableBadgeStylesheet.secondaryLabel, { color: "#000000", opacity: 1 }], + [], ); const nativeShimmerPeakCombinedStyle = useMemo( @@ -1697,7 +1565,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ nativeShimmerPeakStyle, { width: nativeShimmerPeakWidth, height: labelRowHeight }, ], - [labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth] + [labelRowHeight, nativeShimmerPeakStyle, nativeShimmerPeakWidth], ); const chevronStyle = useMemo( @@ -1705,7 +1573,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ expandableBadgeStylesheet.chevron, isExpanded && expandableBadgeStylesheet.chevronExpanded, ], - [isExpanded] + [isExpanded], ); const IconComponent = icon; @@ -1723,10 +1591,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ } return ( - + setIsHovered(true) : undefined} @@ -1770,21 +1635,12 @@ const ExpandableBadge = memo(function ExpandableBadge({ )} {isWebShimmer ? ( - - + + {label} {secondaryLabel ? ( - + {secondaryLabel} ) : ( @@ -1793,25 +1649,16 @@ const ExpandableBadge = memo(function ExpandableBadge({ ) : null} {isNativeShimmer ? ( - + - + {label} {secondaryLabel ? ( - + {secondaryLabel} ) : ( @@ -1820,9 +1667,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ } > - + @@ -1853,11 +1698,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ ) : null} {isInteractive && isHovered ? ( - + ) : null} @@ -1875,10 +1716,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ ); }, areExpandableBadgePropsEqual); -function areExpandableBadgePropsEqual( - previous: ExpandableBadgeProps, - next: ExpandableBadgeProps -) { +function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: ExpandableBadgeProps) { if (previous.label !== next.label) return false; if (previous.secondaryLabel !== next.secondaryLabel) return false; if (previous.icon !== next.icon) return false; @@ -1932,9 +1770,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 = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const effectiveDetail = useMemo(() => { if (detail) { @@ -1950,12 +1786,11 @@ export const ToolCall = memo(function ToolCall({ return undefined; }, [detail, args, result]); - const displayDetail = - effectiveDetail ?? { - type: "unknown", - input: null, - output: null, - }; + const displayDetail = effectiveDetail ?? { + type: "unknown", + input: null, + output: null, + }; const displayModel = useMemo( () => @@ -1967,7 +1802,7 @@ export const ToolCall = memo(function ToolCall({ metadata, cwd, }), - [toolName, status, error, displayDetail, metadata, cwd] + [toolName, status, error, displayDetail, metadata, cwd], ); const displayName = displayModel.displayName; const summary = displayModel.summary; @@ -1981,9 +1816,7 @@ export const ToolCall = memo(function ToolCall({ const secondaryLabel = summary; // Check if there's any content to display - const hasDetails = - Boolean(error) || - hasMeaningfulToolCallDetail(effectiveDetail); + const hasDetails = Boolean(error) || hasMeaningfulToolCallDetail(effectiveDetail); const canOpenDetails = hasDetails || isLoadingDetails; const handleToggle = useCallback(() => { diff --git a/packages/app/src/components/mode-selector-modal.tsx b/packages/app/src/components/mode-selector-modal.tsx index f3d28ca8d..d52d2994a 100644 --- a/packages/app/src/components/mode-selector-modal.tsx +++ b/packages/app/src/components/mode-selector-modal.tsx @@ -18,12 +18,7 @@ export function ModeSelectorModal({ const { theme } = useUnistyles(); return ( - + {agent?.availableModes?.map((mode) => { @@ -35,26 +30,13 @@ export function ModeSelectorModal({ onModeChange(mode.id); onClose(); }} - style={[ - styles.modeItem, - isActive && styles.modeItemActive, - ]} + style={[styles.modeItem, isActive && styles.modeItemActive]} > - + {mode.label} {mode.description && ( - + {mode.description} )} diff --git a/packages/app/src/components/name-host-modal.tsx b/packages/app/src/components/name-host-modal.tsx index 32fbec295..3eb831c3b 100644 --- a/packages/app/src/components/name-host-modal.tsx +++ b/packages/app/src/components/name-host-modal.tsx @@ -81,10 +81,13 @@ export function NameHostModal({ visible, serverId, hostname, onSkip, onSave }: N }, [label, onSave, onSkip]); return ( - - - Optional. You can rename this later in Settings. - + + Optional. You can rename this later in Settings. Label diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 540edce21..b0c3d30e8 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -48,15 +48,25 @@ export interface PairLinkModalProps { onClose: () => void; targetServerId?: string; onCancel?: () => void; - onSaved?: (result: { profile: HostProfile; serverId: string; hostname: string | null; isNewHost: boolean }) => void; + onSaved?: (result: { + profile: HostProfile; + serverId: string; + hostname: string | null; + isNewHost: boolean; + }) => void; } -export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServerId }: PairLinkModalProps) { +export function PairLinkModal({ + visible, + onClose, + onCancel, + onSaved, + targetServerId, +}: PairLinkModalProps) { const { theme } = useUnistyles(); const daemons = useHosts(); const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const [offerUrl, setOfferUrl] = useState(""); const [isSaving, setIsSaving] = useState(false); @@ -148,10 +158,24 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved, targetServe } finally { setIsSaving(false); } - }, [daemons, handleClose, isMobile, isSaving, offerUrl, onSaved, targetServerId, upsertDaemonFromOfferUrl]); + }, [ + daemons, + handleClose, + isMobile, + isSaving, + offerUrl, + onSaved, + targetServerId, + upsertDaemonFromOfferUrl, + ]); return ( - + Paste the pairing link from your server. diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index c9ee903b3..3f8e84b76 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -1,13 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Modal, - Pressable, - ScrollView, - Text, - TextInput, - View, - Platform, -} from "react-native"; +import { Modal, Pressable, ScrollView, Text, TextInput, View, Platform } from "react-native"; import { Folder } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQuery } from "@tanstack/react-query"; @@ -37,7 +29,7 @@ export function ProjectPickerModal() { const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const workspaces = useSessionStore((state) => - serverId ? state.sessions[serverId]?.workspaces : undefined + serverId ? state.sessions[serverId]?.workspaces : undefined, ); const inputRef = useRef(null); @@ -49,7 +41,7 @@ export function ProjectPickerModal() { const recommendedPaths = useMemo(() => { if (!workspaces) return []; return Array.from(workspaces.values()).map( - (workspace) => workspace.projectRootPath || workspace.id + (workspace) => workspace.projectRootPath || workspace.id, ); }, [workspaces]); @@ -64,9 +56,7 @@ export function ProjectPickerModal() { limit: 30, }); return ( - result.entries?.flatMap((entry) => - entry.kind === "directory" ? [entry.path] : [] - ) ?? [] + result.entries?.flatMap((entry) => (entry.kind === "directory" ? [entry.path] : [])) ?? [] ); }, enabled: Boolean(client) && isConnected && open, @@ -81,7 +71,7 @@ export function ProjectPickerModal() { serverPaths: directorySuggestionsQuery.data ?? [], query, }), - [query, directorySuggestionsQuery.data, recommendedPaths] + [query, directorySuggestionsQuery.data, recommendedPaths], ); const handleClose = useCallback(() => { @@ -103,7 +93,7 @@ export function ProjectPickerModal() { setIsSubmitting(false); } }, - [client, openProject, serverId, setOpen] + [client, openProject, serverId, setOpen], ); const handleSubmitCustom = useCallback(() => { @@ -136,13 +126,7 @@ export function ProjectPickerModal() { function handler(event: KeyboardEvent) { const key = event.key; - if ( - key !== "ArrowDown" && - key !== "ArrowUp" && - key !== "Enter" && - key !== "Escape" - ) - return; + if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") return; if (key === "Escape") { event.preventDefault(); @@ -180,12 +164,7 @@ export function ProjectPickerModal() { if (!serverId) return null; return ( - + @@ -198,9 +177,7 @@ export function ProjectPickerModal() { }, ]} > - + {isSubmitting ? ( - + Opening project... ) : options.length === 0 && !query.trim() ? ( - + Start typing a path ) : ( @@ -266,10 +233,7 @@ export function ProjectPickerModal() { /> {shortenPath(path)} diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts index 4bb850589..0089dac56 100644 --- a/packages/app/src/components/provider-icons.ts +++ b/packages/app/src/components/provider-icons.ts @@ -1,12 +1,12 @@ -import { Bot } from 'lucide-react-native' -import { ClaudeIcon } from '@/components/icons/claude-icon' -import { CodexIcon } from '@/components/icons/codex-icon' +import { Bot } from "lucide-react-native"; +import { ClaudeIcon } from "@/components/icons/claude-icon"; +import { CodexIcon } from "@/components/icons/codex-icon"; const PROVIDER_ICONS: Record = { claude: ClaudeIcon as unknown as typeof Bot, codex: CodexIcon as unknown as typeof Bot, -} +}; export function getProviderIcon(provider: string): typeof Bot { - return PROVIDER_ICONS[provider] ?? Bot + return PROVIDER_ICONS[provider] ?? Bot; } diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 7a580906a..f5d711fdc 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -1,12 +1,5 @@ import { useState, useCallback } from "react"; -import { - View, - Text, - TextInput, - Pressable, - ActivityIndicator, - Platform, -} from "react-native"; +import { View, Text, TextInput, Pressable, ActivityIndicator, Platform } from "react-native"; import { StyleSheet, useUnistyles, UnistylesRuntime } from "react-native-unistyles"; import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; @@ -68,52 +61,42 @@ interface QuestionFormCardProps { const IS_WEB = Platform.OS === "web"; -export function QuestionFormCard({ - permission, - onRespond, - isResponding, -}: QuestionFormCardProps) { +export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) { const { theme } = useUnistyles(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const questions = parseQuestions(permission.request.input); const [selections, setSelections] = useState>>({}); const [otherTexts, setOtherTexts] = useState>({}); - const [respondingAction, setRespondingAction] = useState< - "submit" | "dismiss" | null - >(null); + const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null); - const toggleOption = useCallback( - (qIndex: number, optIndex: number, multiSelect: boolean) => { - setSelections((prev) => { - const current = prev[qIndex] ?? new Set(); - const next = new Set(current); - if (multiSelect) { - if (next.has(optIndex)) { - next.delete(optIndex); - } else { - next.add(optIndex); - } + const toggleOption = useCallback((qIndex: number, optIndex: number, multiSelect: boolean) => { + setSelections((prev) => { + const current = prev[qIndex] ?? new Set(); + const next = new Set(current); + if (multiSelect) { + if (next.has(optIndex)) { + next.delete(optIndex); } else { - if (next.has(optIndex)) { - next.clear(); - } else { - next.clear(); - next.add(optIndex); - } + next.add(optIndex); } - return { ...prev, [qIndex]: next }; - }); - setOtherTexts((prev) => { - if (!prev[qIndex]) return prev; - const next = { ...prev }; - delete next[qIndex]; - return next; - }); - }, - [] - ); + } else { + if (next.has(optIndex)) { + next.clear(); + } else { + next.clear(); + next.add(optIndex); + } + } + return { ...prev, [qIndex]: next }; + }); + setOtherTexts((prev) => { + if (!prev[qIndex]) return prev; + const next = { ...prev }; + delete next[qIndex]; + return next; + }); + }, []); const setOtherText = useCallback((qIndex: number, text: string) => { setOtherTexts((prev) => ({ ...prev, [qIndex]: text })); @@ -182,9 +165,7 @@ export function QuestionFormCard({ return ( - + {q.question} @@ -202,19 +183,12 @@ export function QuestionFormCard({ }, pressed && styles.optionItemPressed, ]} - onPress={() => - toggleOption(qIndex, optIndex, q.multiSelect) - } + onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)} disabled={isResponding} > - + {opt.label} {opt.description ? ( @@ -230,10 +204,7 @@ export function QuestionFormCard({ {isSelected ? ( - + ) : null} @@ -245,9 +216,8 @@ export function QuestionFormCard({ style={[ styles.otherInput, { - borderColor: otherText.length > 0 - ? theme.colors.borderAccent - : theme.colors.border, + borderColor: + otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border, color: theme.colors.foreground, backgroundColor: theme.colors.surface2, }, @@ -264,19 +234,12 @@ export function QuestionFormCard({ ); })} - + [ styles.actionButton, { - backgroundColor: hovered - ? theme.colors.surface2 - : theme.colors.surface1, + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, borderColor: theme.colors.borderAccent, }, pressed && styles.optionItemPressed, @@ -285,19 +248,11 @@ export function QuestionFormCard({ disabled={isResponding} > {respondingAction === "dismiss" ? ( - + ) : ( - + Dismiss @@ -311,12 +266,8 @@ export function QuestionFormCard({ styles.actionButton, { backgroundColor: - hovered && !disabled - ? theme.colors.surface2 - : theme.colors.surface1, - borderColor: disabled - ? theme.colors.border - : theme.colors.borderAccent, + hovered && !disabled ? theme.colors.surface2 : theme.colors.surface1, + borderColor: disabled ? theme.colors.border : theme.colors.borderAccent, opacity: disabled ? 0.5 : 1, }, pressed && !disabled ? styles.optionItemPressed : null, @@ -326,27 +277,18 @@ export function QuestionFormCard({ disabled={!allAnswered || isResponding} > {respondingAction === "submit" ? ( - + ) : ( diff --git a/packages/app/src/components/resize-handle.tsx b/packages/app/src/components/resize-handle.tsx index cc46b5837..a5bad521a 100644 --- a/packages/app/src/components/resize-handle.tsx +++ b/packages/app/src/components/resize-handle.tsx @@ -73,8 +73,7 @@ export function ResizeHandle({ return; } - const pointerCurrent = - direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY; + const pointerCurrent = direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY; const deltaRatio = (pointerCurrent - pointerState.pointerStart) / pointerState.containerSize; @@ -91,7 +90,7 @@ export function ResizeHandle({ window.addEventListener("pointermove", handlePointerMove); window.addEventListener("pointerup", handlePointerUp, { once: true }); }, - [direction, groupId, index, onResizeSplit, sizes] + [direction, groupId, index, onResizeSplit, sizes], ); return ( @@ -107,9 +106,7 @@ export function ResizeHandle({ pointerEvents="none" style={[ styles.highlight, - direction === "horizontal" - ? styles.highlightHorizontal - : styles.highlightVertical, + direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical, { backgroundColor: theme.colors.accent }, ]} /> diff --git a/packages/app/src/components/sidebar-agent-list-skeleton.tsx b/packages/app/src/components/sidebar-agent-list-skeleton.tsx index 141be8492..835abf361 100644 --- a/packages/app/src/components/sidebar-agent-list-skeleton.tsx +++ b/packages/app/src/components/sidebar-agent-list-skeleton.tsx @@ -2,13 +2,7 @@ import { useEffect, useRef } from "react"; import { Animated, View, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; -function SkeletonPulse({ - pulse, - style, -}: { - pulse: Animated.Value; - style: StyleProp; -}) { +function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp }) { const opacity = pulse.interpolate({ inputRange: [0, 1], outputRange: [0.4, 0.8], @@ -33,7 +27,7 @@ export function SidebarAgentListSkeleton() { duration: 1000, useNativeDriver: true, }), - ]) + ]), ); animation.start(); @@ -55,10 +49,7 @@ export function SidebarAgentListSkeleton() { {Array.from({ length: 3 }).map((__, rowIdx) => ( - + diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 3e015d854..dab204305 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -8,9 +8,9 @@ import { StatusBar, ScrollView, type GestureResponderEvent, -} from 'react-native' -import * as Haptics from 'expo-haptics' -import { useQueries } from '@tanstack/react-query' +} from "react-native"; +import * as Haptics from "expo-haptics"; +import { useQueries } from "@tanstack/react-query"; import { useCallback, useMemo, @@ -19,143 +19,146 @@ import { useRef, type ReactElement, type MutableRefObject, -} from 'react' -import { router, usePathname } from 'expo-router' -import { navigateToWorkspace } from '@/hooks/use-workspace-navigation' -import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' -import { type GestureType } from 'react-native-gesture-handler' -import * as Clipboard from 'expo-clipboard' -import { Archive, Check, ChevronDown, ChevronRight, CircleHelp, Copy, FolderGit2, Monitor, MoreVertical, Plus } from 'lucide-react-native' -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 { getIsDesktop } from '@/constants/layout' -import { projectIconQueryKey } from '@/hooks/use-project-icon-query' +} from "react"; +import { router, usePathname } from "expo-router"; +import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { type GestureType } from "react-native-gesture-handler"; +import * as Clipboard from "expo-clipboard"; import { - parseHostWorkspaceRouteFromPathname, -} from '@/utils/host-routes' -import { prepareWorkspaceTab } from '@/utils/workspace-navigation' + Archive, + Check, + ChevronDown, + ChevronRight, + CircleHelp, + Copy, + FolderGit2, + Monitor, + MoreVertical, + Plus, +} from "lucide-react-native"; +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 { getIsDesktop } from "@/constants/layout"; +import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; +import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { type SidebarProjectEntry, type SidebarWorkspaceEntry, type SidebarStateBucket, -} from '@/hooks/use-sidebar-workspaces-list' -import { useSidebarOrderStore } from '@/stores/sidebar-order-store' -import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' +} from "@/hooks/use-sidebar-workspaces-list"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, useContextMenu, -} from '@/components/ui/context-menu' +} from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, -} from '@/components/ui/dropdown-menu' -import { SyncedLoader } from '@/components/synced-loader' -import { useToast } from '@/contexts/toast-context' -import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' -import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder' -import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration' -import { confirmDialog } from '@/utils/confirm-dialog' -import { projectIconPlaceholderLabelFromDisplayName } from '@/utils/project-display-name' -import { shouldRenderSyncedStatusLoader } from '@/utils/status-loader' -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { buildSidebarProjectRowModel } from '@/utils/sidebar-project-row-model' -import { useNavigationActiveWorkspaceSelection } from '@/stores/navigation-active-workspace-store' -import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store' -import { createNameId } from 'mnemonic-id' -import { buildWorkspaceArchiveRedirectRoute } from '@/utils/workspace-archive-navigation' +} from "@/components/ui/dropdown-menu"; +import { SyncedLoader } from "@/components/synced-loader"; +import { useToast } from "@/contexts/toast-context"; +import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; +import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; +import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; +import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { createNameId } from "mnemonic-id"; +import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { - return null + return null; } - return `data:${icon.mimeType};base64,${icon.data}` + return `data:${icon.mimeType};base64,${icon.data}`; } -const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => - workspace.workspaceKey +const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; -const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey -const EMPTY_WORKSPACES = new Map() +const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; +const EMPTY_WORKSPACES = new Map(); interface SidebarWorkspaceListProps { - projects: SidebarProjectEntry[] - serverId: string | null - collapsedProjectKeys: ReadonlySet - onToggleProjectCollapsed: (projectKey: string) => void - onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void - shortcutIndexByWorkspaceKey: Map - isRefreshing?: boolean - onRefresh?: () => void - onWorkspacePress?: () => void - listFooterComponent?: ReactElement | null + projects: SidebarProjectEntry[]; + serverId: string | null; + collapsedProjectKeys: ReadonlySet; + onToggleProjectCollapsed: (projectKey: string) => void; + onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void; + shortcutIndexByWorkspaceKey: Map; + isRefreshing?: boolean; + onRefresh?: () => void; + onWorkspacePress?: () => void; + listFooterComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ - parentGestureRef?: MutableRefObject + parentGestureRef?: MutableRefObject; } interface ProjectHeaderRowProps { - project: SidebarProjectEntry - displayName: string - iconDataUri: string | null - workspace: SidebarWorkspaceEntry | null - selected?: boolean - chevron: 'expand' | 'collapse' | null - onPress: () => void - onCreateWorktree?: () => void - shortcutNumber?: number | null - showShortcutBadge?: boolean - drag: () => void - isDragging: boolean - isArchiving?: boolean - menuController: ReturnType | null - dragHandleProps?: DraggableListDragHandleProps + project: SidebarProjectEntry; + displayName: string; + iconDataUri: string | null; + workspace: SidebarWorkspaceEntry | null; + selected?: boolean; + chevron: "expand" | "collapse" | null; + onPress: () => void; + onCreateWorktree?: () => void; + shortcutNumber?: number | null; + showShortcutBadge?: boolean; + drag: () => void; + isDragging: boolean; + isArchiving?: boolean; + menuController: ReturnType | null; + dragHandleProps?: DraggableListDragHandleProps; } interface WorkspaceRowInnerProps { - workspace: SidebarWorkspaceEntry - selected: boolean - shortcutNumber: number | null - showShortcutBadge: boolean - onPress: () => void - drag: () => void - isDragging: boolean - isArchiving: boolean - isCreating?: boolean - dragHandleProps?: DraggableListDragHandleProps - menuController: ReturnType | null - archiveLabel?: string - archiveStatus?: 'idle' | 'pending' | 'success' - archivePendingLabel?: string - onArchive?: () => void - onCopyBranchName?: () => void - onCopyPath?: () => void + workspace: SidebarWorkspaceEntry; + selected: boolean; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onPress: () => void; + drag: () => void; + isDragging: boolean; + isArchiving: boolean; + isCreating?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + menuController: ReturnType | null; + archiveLabel?: string; + archiveStatus?: "idle" | "pending" | "success"; + archivePendingLabel?: string; + onArchive?: () => void; + onCopyBranchName?: () => void; + onCopyPath?: () => void; } - function resolveStatusDotColor(input: { - theme: ReturnType['theme'] - bucket: SidebarStateBucket + theme: ReturnType["theme"]; + bucket: SidebarStateBucket; }) { - const { theme, bucket } = input - return bucket === 'needs_input' + const { theme, bucket } = input; + return bucket === "needs_input" ? theme.colors.palette.amber[500] - : bucket === 'failed' + : bucket === "failed" ? theme.colors.palette.red[500] - : bucket === 'running' + : bucket === "running" ? theme.colors.palette.blue[500] - : bucket === 'attention' + : bucket === "attention" ? theme.colors.palette.green[500] - : theme.colors.border + : theme.colors.border; } function WorkspaceStatusIndicator({ @@ -163,23 +166,28 @@ function WorkspaceStatusIndicator({ workspaceKind, loading = false, }: { - bucket: SidebarWorkspaceEntry['statusBucket'] - workspaceKind: SidebarWorkspaceEntry['workspaceKind'] - loading?: boolean + bucket: SidebarWorkspaceEntry["statusBucket"]; + workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; + loading?: boolean; }) { - const { theme } = useUnistyles() - const color = resolveStatusDotColor({ theme, bucket }) - const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }) - const isIdle = !loading && !shouldShowSyncedLoader && bucket === 'done' + const { theme } = useUnistyles(); + const color = resolveStatusDotColor({ theme, bucket }); + const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); + const isIdle = !loading && !shouldShowSyncedLoader && bucket === "done"; if (isIdle) { - const KindIcon = workspaceKind === 'local_checkout' ? Monitor : workspaceKind === 'worktree' ? FolderGit2 : null - if (!KindIcon) return null + const KindIcon = + workspaceKind === "local_checkout" + ? Monitor + : workspaceKind === "worktree" + ? FolderGit2 + : null; + if (!KindIcon) return null; return ( - ) + ); } return ( @@ -188,15 +196,15 @@ function WorkspaceStatusIndicator({ ) : shouldShowSyncedLoader ? ( - ) : bucket === 'needs_input' ? ( + ) : bucket === "needs_input" ? ( - ) : bucket === 'attention' ? ( + ) : bucket === "attention" ? ( ) : ( )} - ) + ); } function ProjectLeadingVisual({ @@ -207,25 +215,29 @@ function ProjectLeadingVisual({ showChevron = false, isArchiving = false, }: { - displayName: string - iconDataUri: string | null - workspace: SidebarWorkspaceEntry | null - chevron?: 'expand' | 'collapse' | null - showChevron?: boolean - isArchiving?: boolean + displayName: string; + iconDataUri: string | null; + workspace: SidebarWorkspaceEntry | null; + chevron?: "expand" | "collapse" | null; + showChevron?: boolean; + isArchiving?: boolean; }) { - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName) - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() - const activeWorkspace = workspace + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); + const activeWorkspace = workspace; const shouldShowWorkspaceStatus = - activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== 'done') + activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done"); return ( {showChevron && chevron !== null ? ( ) : shouldShowWorkspaceStatus && activeWorkspace ? ( - + ) : iconDataUri ? ( ) : ( @@ -234,21 +246,17 @@ function ProjectLeadingVisual({ )} - ) + ); } -function ProjectInlineChevron({ - chevron, -}: { - chevron: 'expand' | 'collapse' | null -}) { +function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { if (chevron === null) { - return null + return null; } - if (chevron === 'collapse') { - return + if (chevron === "collapse") { + return ; } - return + return ; } function NewWorktreeButton({ @@ -257,15 +265,15 @@ function NewWorktreeButton({ visible, testID, }: { - displayName: string - onPress: () => void - visible: boolean - testID: string + displayName: string; + onPress: () => void; + visible: boolean; + testID: string; }) { - const { theme } = useUnistyles() + const { theme } = useUnistyles(); return ( - + { - event.stopPropagation() - onPress() + event.stopPropagation(); + onPress(); }} accessibilityRole="button" accessibilityLabel={`Create a new worktree for ${displayName}`} @@ -295,213 +303,216 @@ function NewWorktreeButton({ - ) + ); } function useLongPressDragInteraction(input: { - drag: () => void - menuController: ReturnType | null + drag: () => void; + menuController: ReturnType | null; }) { - const didLongPressRef = useRef(false) - const dragArmedRef = useRef(false) - const dragActivatedRef = useRef(false) - const didStartDragRef = useRef(false) - const scrollIntentRef = useRef(false) - const menuOpenedRef = useRef(false) - const touchStartRef = useRef<{ x: number; y: number } | null>(null) - const touchCurrentRef = useRef<{ x: number; y: number } | null>(null) - const dragArmTimerRef = useRef | null>(null) - const contextMenuTimerRef = useRef | null>(null) + const didLongPressRef = useRef(false); + const dragArmedRef = useRef(false); + const dragActivatedRef = useRef(false); + const didStartDragRef = useRef(false); + const scrollIntentRef = useRef(false); + const menuOpenedRef = useRef(false); + const touchStartRef = useRef<{ x: number; y: number } | null>(null); + const touchCurrentRef = useRef<{ x: number; y: number } | null>(null); + const dragArmTimerRef = useRef | null>(null); + const contextMenuTimerRef = useRef | null>(null); const clearTimers = useCallback(() => { if (dragArmTimerRef.current) { - clearTimeout(dragArmTimerRef.current) - dragArmTimerRef.current = null + clearTimeout(dragArmTimerRef.current); + dragArmTimerRef.current = null; } if (contextMenuTimerRef.current) { - clearTimeout(contextMenuTimerRef.current) - contextMenuTimerRef.current = null + clearTimeout(contextMenuTimerRef.current); + contextMenuTimerRef.current = null; } - }, []) + }, []); const openContextMenuAtStartPoint = useCallback(() => { if (!input.menuController || !touchStartRef.current) { - return + return; } - const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; input.menuController.setAnchorRect({ x: touchStartRef.current.x, y: touchStartRef.current.y + statusBarHeight, width: 0, height: 0, - }) - input.menuController.setOpen(true) - menuOpenedRef.current = true - didLongPressRef.current = true - }, [input.menuController]) + }); + input.menuController.setOpen(true); + menuOpenedRef.current = true; + didLongPressRef.current = true; + }, [input.menuController]); const handleLongPress = useCallback(() => { // Manual timers own long-press behavior on mobile. - }, []) + }, []); useEffect(() => { return () => { - clearTimers() - } - }, [clearTimers]) + clearTimers(); + }; + }, [clearTimers]); const armTimers = useCallback(() => { - clearTimers() + clearTimers(); - const DRAG_ARM_DELAY_MS = 180 - const DRAG_ARM_STATIONARY_SLOP_PX = 4 - const CONTEXT_MENU_DELAY_MS = 450 - const CONTEXT_MENU_STATIONARY_SLOP_PX = 6 + const DRAG_ARM_DELAY_MS = 180; + const DRAG_ARM_STATIONARY_SLOP_PX = 4; + const CONTEXT_MENU_DELAY_MS = 450; + const CONTEXT_MENU_STATIONARY_SLOP_PX = 6; dragArmTimerRef.current = setTimeout(() => { if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { - return + return; } - const start = touchStartRef.current - const current = touchCurrentRef.current ?? start + const start = touchStartRef.current; + const current = touchCurrentRef.current ?? start; if (!start || !current) { - return + return; } - const dx = current.x - start.x - const dy = current.y - start.y - const distance = Math.sqrt(dx * dx + dy * dy) + const dx = current.x - start.x; + const dy = current.y - start.y; + const distance = Math.sqrt(dx * dx + dy * dy); if (distance > DRAG_ARM_STATIONARY_SLOP_PX) { - return + return; } - dragArmedRef.current = true - dragActivatedRef.current = true - didLongPressRef.current = true - void Haptics.selectionAsync().catch(() => {}) - input.drag() - }, DRAG_ARM_DELAY_MS) + dragArmedRef.current = true; + dragActivatedRef.current = true; + didLongPressRef.current = true; + void Haptics.selectionAsync().catch(() => {}); + input.drag(); + }, DRAG_ARM_DELAY_MS); - if (!input.menuController || Platform.OS === 'web') { - return + if (!input.menuController || Platform.OS === "web") { + return; } contextMenuTimerRef.current = setTimeout(() => { if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { - return + return; } - const start = touchStartRef.current - const current = touchCurrentRef.current ?? start + const start = touchStartRef.current; + const current = touchCurrentRef.current ?? start; if (!start || !current) { - return + return; } - const dx = current.x - start.x - const dy = current.y - start.y - const distance = Math.sqrt(dx * dx + dy * dy) + const dx = current.x - start.x; + const dy = current.y - start.y; + const distance = Math.sqrt(dx * dx + dy * dy); if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) { - return + return; } - void Haptics.selectionAsync().catch(() => {}) - openContextMenuAtStartPoint() - }, CONTEXT_MENU_DELAY_MS) - }, [clearTimers, input.menuController, openContextMenuAtStartPoint]) + void Haptics.selectionAsync().catch(() => {}); + openContextMenuAtStartPoint(); + }, CONTEXT_MENU_DELAY_MS); + }, [clearTimers, input.menuController, openContextMenuAtStartPoint]); const handleDragIntent = useCallback( (details: { dx: number; dy: number; distance: number }) => { if (!dragActivatedRef.current) { - return + return; } - didStartDragRef.current = true - didLongPressRef.current = true - clearTimers() - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}) + didStartDragRef.current = true; + didLongPressRef.current = true; + clearTimers(); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); }, - [clearTimers] - ) + [clearTimers], + ); const handleScrollIntent = useCallback( (details: { dx: number; dy: number; distance: number }) => { - scrollIntentRef.current = true - didLongPressRef.current = true - clearTimers() + scrollIntentRef.current = true; + didLongPressRef.current = true; + clearTimers(); }, - [clearTimers] - ) + [clearTimers], + ); const handleSwipeIntent = useCallback( (details: { dx: number; dy: number; distance: number }) => { - didLongPressRef.current = true - clearTimers() + didLongPressRef.current = true; + clearTimers(); }, - [clearTimers] - ) + [clearTimers], + ); - const handlePressIn = useCallback((event: GestureResponderEvent) => { - didLongPressRef.current = false - dragArmedRef.current = false - dragActivatedRef.current = false - didStartDragRef.current = false - scrollIntentRef.current = false - menuOpenedRef.current = false - touchStartRef.current = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - } - touchCurrentRef.current = { - x: event.nativeEvent.pageX, - y: event.nativeEvent.pageY, - } - armTimers() - }, [armTimers]) + const handlePressIn = useCallback( + (event: GestureResponderEvent) => { + didLongPressRef.current = false; + dragArmedRef.current = false; + dragActivatedRef.current = false; + didStartDragRef.current = false; + scrollIntentRef.current = false; + menuOpenedRef.current = false; + touchStartRef.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + touchCurrentRef.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + }; + armTimers(); + }, + [armTimers], + ); const handleTouchMove = useCallback( (event: any) => { - const start = touchStartRef.current + const start = touchStartRef.current; if (!start || didStartDragRef.current || menuOpenedRef.current) { - return + return; } - const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent - const x = touch?.pageX - const y = touch?.pageY - if (typeof x !== 'number' || typeof y !== 'number') { - return + const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent; + const x = touch?.pageX; + const y = touch?.pageY; + if (typeof x !== "number" || typeof y !== "number") { + return; } - const current = { x, y } - touchCurrentRef.current = current - const dx = current.x - start.x - const dy = current.y - start.y - const distance = Math.sqrt(dx * dx + dy * dy) + const current = { x, y }; + touchCurrentRef.current = current; + const dx = current.x - start.x; + const dy = current.y - start.y; + const distance = Math.sqrt(dx * dx + dy * dy); const decision = decideLongPressMove({ dragArmed: dragArmedRef.current, didStartDrag: didStartDragRef.current, startPoint: start, currentPoint: current, - }) + }); - if (decision === 'vertical_scroll') { - handleScrollIntent({ dx, dy, distance }) - return + if (decision === "vertical_scroll") { + handleScrollIntent({ dx, dy, distance }); + return; } - if (decision === 'horizontal_swipe' || decision === 'cancel_long_press') { - handleSwipeIntent({ dx, dy, distance }) - return + if (decision === "horizontal_swipe" || decision === "cancel_long_press") { + handleSwipeIntent({ dx, dy, distance }); + return; } - if (decision === 'start_drag') { - handleDragIntent({ dx, dy, distance }) + if (decision === "start_drag") { + handleDragIntent({ dx, dy, distance }); } }, - [handleDragIntent, handleScrollIntent, handleSwipeIntent] - ) + [handleDragIntent, handleScrollIntent, handleSwipeIntent], + ); const handlePressOut = useCallback(() => { - clearTimers() - dragArmedRef.current = false - dragActivatedRef.current = false - touchStartRef.current = null - touchCurrentRef.current = null - }, [clearTimers]) + clearTimers(); + dragArmedRef.current = false; + dragActivatedRef.current = false; + touchStartRef.current = null; + touchCurrentRef.current = null; + }, [clearTimers]); return { didLongPressRef, @@ -509,7 +520,7 @@ function useLongPressDragInteraction(input: { handlePressIn, handleTouchMove, handlePressOut, - } + }; } function ProjectHeaderRow({ @@ -529,19 +540,19 @@ function ProjectHeaderRow({ menuController, dragHandleProps, }: ProjectHeaderRowProps) { - const [isHovered, setIsHovered] = useState(false) + const [isHovered, setIsHovered] = useState(false); const interaction = useLongPressDragInteraction({ drag, menuController, - }) + }); const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { - interaction.didLongPressRef.current = false - return + interaction.didLongPressRef.current = false; + return; } - onPress() - }, [interaction.didLongPressRef, onPress]) + onPress(); + }, [interaction.didLongPressRef, onPress]); const rowChildren = ( <> @@ -580,14 +591,11 @@ function ProjectHeaderRow({ ) : null} - ) + ); if (menuController) { return ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - > + setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> [ @@ -606,14 +614,11 @@ function ProjectHeaderRow({ {rowChildren} - ) + ); } return ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - > + setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> [ styles.projectRow, @@ -631,7 +636,7 @@ function ProjectHeaderRow({ {rowChildren} - ) + ); } function WorkspaceRowInner({ @@ -647,27 +652,27 @@ function WorkspaceRowInner({ dragHandleProps, menuController, archiveLabel, - archiveStatus = 'idle', + archiveStatus = "idle", archivePendingLabel, onArchive, onCopyBranchName, onCopyPath, }: WorkspaceRowInnerProps) { - const { theme } = useUnistyles() - const [isHovered, setIsHovered] = useState(false) - const isMobile = Platform.OS !== 'web' + const { theme } = useUnistyles(); + const [isHovered, setIsHovered] = useState(false); + const isMobile = Platform.OS !== "web"; const interaction = useLongPressDragInteraction({ drag, menuController, - }) + }); const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { - interaction.didLongPressRef.current = false - return + interaction.didLongPressRef.current = false; + return; } - onPress() - }, [interaction.didLongPressRef, onPress]) + onPress(); + }, [interaction.didLongPressRef, onPress]); return ( - {archiveLabel ?? 'Archive'} + {archiveLabel ?? "Archive"} @@ -777,7 +782,7 @@ function WorkspaceRowInner({ - ) + ); } function WorkspaceRowWithMenu({ @@ -792,39 +797,39 @@ function WorkspaceRowWithMenu({ canCopyBranchName, isCreating = false, }: { - workspace: SidebarWorkspaceEntry - selected: boolean - shortcutNumber: number | null - showShortcutBadge: boolean - onPress: () => void - drag: () => void - isDragging: boolean - dragHandleProps?: DraggableListDragHandleProps - canCopyBranchName: boolean - isCreating?: boolean + workspace: SidebarWorkspaceEntry; + selected: boolean; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onPress: () => void; + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableListDragHandleProps; + canCopyBranchName: boolean; + isCreating?: boolean; }) { - const toast = useToast() - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() - const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) + const toast = useToast(); + const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); + const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const sessionWorkspaces = useSessionStore( - (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES - ) - const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) + (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, + ); + const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const archiveStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId: workspace.serverId, cwd: workspace.workspaceId, - actionId: 'archive-worktree', - }) - ) - const isWorktree = workspace.workspaceKind === 'worktree' - const isArchiving = isWorktree ? archiveStatus === 'pending' : isArchivingWorkspace + actionId: "archive-worktree", + }), + ); + const isWorktree = workspace.workspaceKind === "worktree"; + const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace; const redirectAfterArchive = useCallback(() => { if ( activeWorkspaceSelection?.serverId !== workspace.serverId || activeWorkspaceSelection.workspaceId !== workspace.workspaceId ) { - return + return; } router.replace( @@ -832,44 +837,41 @@ function WorkspaceRowWithMenu({ serverId: workspace.serverId, archivedWorkspaceId: workspace.workspaceId, workspaces: sessionWorkspaces.values(), - }) as any - ) - }, [ - activeWorkspaceSelection, - sessionWorkspaces, - workspace.serverId, - workspace.workspaceId, - ]) + }) as any, + ); + }, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]); const handleArchiveWorktree = useCallback(() => { if (isArchiving) { - return + return; } void (async () => { const confirmed = await confirmDialog({ - title: 'Archive worktree?', + title: "Archive worktree?", message: `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`, - confirmLabel: 'Archive', - cancelLabel: 'Cancel', + confirmLabel: "Archive", + cancelLabel: "Cancel", destructive: true, - }) + }); if (!confirmed) { - return + return; } void archiveWorktree({ serverId: workspace.serverId, cwd: workspace.workspaceId, worktreePath: workspace.workspaceId, - }).then(() => { - redirectAfterArchive() - }).catch((error) => { - const message = error instanceof Error ? error.message : 'Failed to archive worktree' - toast.error(message) }) - })() + .then(() => { + redirectAfterArchive(); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : "Failed to archive worktree"; + toast.error(message); + }); + })(); }, [ archiveWorktree, isArchiving, @@ -878,44 +880,44 @@ function WorkspaceRowWithMenu({ workspace.name, workspace.serverId, workspace.workspaceId, - ]) + ]); const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { - return + return; } void (async () => { const confirmed = await confirmDialog({ - title: 'Hide workspace?', + title: "Hide workspace?", message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: 'Hide', - cancelLabel: 'Cancel', + confirmLabel: "Hide", + cancelLabel: "Cancel", destructive: true, - }) + }); if (!confirmed) { - return + return; } - const client = getHostRuntimeStore().getClient(workspace.serverId) + const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { - toast.error('Host is not connected') - return + toast.error("Host is not connected"); + return; } - setIsArchivingWorkspace(true) + setIsArchivingWorkspace(true); try { - const payload = await client.archiveWorkspace(workspace.workspaceId) + const payload = await client.archiveWorkspace(workspace.workspaceId); if (payload.error) { - throw new Error(payload.error) + throw new Error(payload.error); } - redirectAfterArchive() + redirectAfterArchive(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') + toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { - setIsArchivingWorkspace(false) + setIsArchivingWorkspace(false); } - })() + })(); }, [ isArchivingWorkspace, redirectAfterArchive, @@ -923,17 +925,17 @@ function WorkspaceRowWithMenu({ workspace.name, workspace.serverId, workspace.workspaceId, - ]) + ]); const handleCopyPath = useCallback(() => { - void Clipboard.setStringAsync(workspace.workspaceId) - toast.copied('Path copied') - }, [toast, workspace.workspaceId]) + void Clipboard.setStringAsync(workspace.workspaceId); + toast.copied("Path copied"); + }, [toast, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { - void Clipboard.setStringAsync(workspace.name) - toast.copied('Branch name copied') - }, [toast, workspace.name]) + void Clipboard.setStringAsync(workspace.name); + toast.copied("Branch name copied"); + }, [toast, workspace.name]); return ( - ) + ); } function NonGitProjectRowWithMenuContent({ @@ -971,31 +973,31 @@ function NonGitProjectRowWithMenuContent({ isDragging, dragHandleProps, }: { - project: SidebarProjectEntry - displayName: string - iconDataUri: string | null - workspace: SidebarWorkspaceEntry - selected: boolean - onPress: () => void - shortcutNumber: number | null - showShortcutBadge: boolean - drag: () => void - isDragging: boolean - dragHandleProps?: DraggableListDragHandleProps + project: SidebarProjectEntry; + displayName: string; + iconDataUri: string | null; + workspace: SidebarWorkspaceEntry; + selected: boolean; + onPress: () => void; + shortcutNumber: number | null; + showShortcutBadge: boolean; + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableListDragHandleProps; }) { - const toast = useToast() - const contextMenu = useContextMenu() - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() + const toast = useToast(); + const contextMenu = useContextMenu(); + const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const sessionWorkspaces = useSessionStore( - (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES - ) - const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false) + (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, + ); + const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const redirectAfterArchive = useCallback(() => { if ( activeWorkspaceSelection?.serverId !== workspace.serverId || activeWorkspaceSelection.workspaceId !== workspace.workspaceId ) { - return + return; } router.replace( @@ -1003,51 +1005,46 @@ function NonGitProjectRowWithMenuContent({ serverId: workspace.serverId, archivedWorkspaceId: workspace.workspaceId, workspaces: sessionWorkspaces.values(), - }) as any - ) - }, [ - activeWorkspaceSelection, - sessionWorkspaces, - workspace.serverId, - workspace.workspaceId, - ]) + }) as any, + ); + }, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]); const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { - return + return; } void (async () => { const confirmed = await confirmDialog({ - title: 'Hide workspace?', + title: "Hide workspace?", message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, - confirmLabel: 'Hide', - cancelLabel: 'Cancel', + confirmLabel: "Hide", + cancelLabel: "Cancel", destructive: true, - }) + }); if (!confirmed) { - return + return; } - const client = getHostRuntimeStore().getClient(workspace.serverId) + const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { - toast.error('Host is not connected') - return + toast.error("Host is not connected"); + return; } - setIsArchivingWorkspace(true) + setIsArchivingWorkspace(true); try { - const payload = await client.archiveWorkspace(workspace.workspaceId) + const payload = await client.archiveWorkspace(workspace.workspaceId); if (payload.error) { - throw new Error(payload.error) + throw new Error(payload.error); } - redirectAfterArchive() + redirectAfterArchive(); } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to hide workspace') + toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { - setIsArchivingWorkspace(false) + setIsArchivingWorkspace(false); } - })() + })(); }, [ isArchivingWorkspace, redirectAfterArchive, @@ -1055,7 +1052,7 @@ function NonGitProjectRowWithMenuContent({ workspace.name, workspace.serverId, workspace.workspaceId, - ]) + ]); return ( <> @@ -1083,7 +1080,7 @@ function NonGitProjectRowWithMenuContent({ > - ) + ); } function NonGitProjectRowWithMenu(props: { - project: SidebarProjectEntry - displayName: string - iconDataUri: string | null - workspace: SidebarWorkspaceEntry - selected: boolean - onPress: () => void - shortcutNumber: number | null - showShortcutBadge: boolean - drag: () => void - isDragging: boolean - dragHandleProps?: DraggableListDragHandleProps + project: SidebarProjectEntry; + displayName: string; + iconDataUri: string | null; + workspace: SidebarWorkspaceEntry; + selected: boolean; + onPress: () => void; + shortcutNumber: number | null; + showShortcutBadge: boolean; + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableListDragHandleProps; }) { return ( - ) + ); } function FlattenedProjectRow({ @@ -1128,19 +1125,19 @@ function FlattenedProjectRow({ isDragging, dragHandleProps, }: { - project: SidebarProjectEntry - displayName: string - iconDataUri: string | null - rowModel: Extract, { kind: 'workspace_link' }> - onPress: () => void - onCreateWorktree?: () => void - shortcutNumber: number | null - showShortcutBadge: boolean - drag: () => void - isDragging: boolean - dragHandleProps?: DraggableListDragHandleProps + project: SidebarProjectEntry; + displayName: string; + iconDataUri: string | null; + rowModel: Extract, { kind: "workspace_link" }>; + onPress: () => void; + onCreateWorktree?: () => void; + shortcutNumber: number | null; + showShortcutBadge: boolean; + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableListDragHandleProps; }) { - if (project.projectKind === 'non_git') { + if (project.projectKind === "non_git") { return ( - ) + ); } return ( @@ -1167,7 +1164,7 @@ function FlattenedProjectRow({ selected={rowModel.selected} chevron={rowModel.chevron} onPress={onPress} - onCreateWorktree={rowModel.trailingAction === 'new_worktree' ? onCreateWorktree : undefined} + onCreateWorktree={rowModel.trailingAction === "new_worktree" ? onCreateWorktree : undefined} shortcutNumber={shortcutNumber} showShortcutBadge={showShortcutBadge} drag={drag} @@ -1175,7 +1172,7 @@ function FlattenedProjectRow({ menuController={null} dragHandleProps={dragHandleProps} /> - ) + ); } function WorkspaceRow({ @@ -1190,16 +1187,16 @@ function WorkspaceRow({ canCopyBranchName, isCreating = false, }: { - workspace: SidebarWorkspaceEntry - selected: boolean - shortcutNumber: number | null - showShortcutBadge: boolean - onPress: () => void - drag: () => void - isDragging: boolean - dragHandleProps?: DraggableListDragHandleProps - canCopyBranchName: boolean - isCreating?: boolean + workspace: SidebarWorkspaceEntry; + selected: boolean; + shortcutNumber: number | null; + showShortcutBadge: boolean; + onPress: () => void; + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableListDragHandleProps; + canCopyBranchName: boolean; + isCreating?: boolean; }) { return ( - ) + ); } function ProjectBlock({ @@ -1237,24 +1234,24 @@ function ProjectBlock({ useNestable, creatingWorkspaceIds, }: { - project: SidebarProjectEntry - collapsed: boolean - displayName: string - iconDataUri: string | null - serverId: string | null - activeWorkspaceSelection: { serverId: string; workspaceId: string } | null - showShortcutBadges: boolean - shortcutIndexByWorkspaceKey: Map - parentGestureRef?: MutableRefObject - onToggleCollapsed: () => void - onWorkspacePress?: () => void - onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void - onCreateWorktree?: (project: SidebarProjectEntry) => void - drag: () => void - isDragging: boolean - dragHandleProps?: DraggableListDragHandleProps - useNestable: boolean - creatingWorkspaceIds: ReadonlySet + project: SidebarProjectEntry; + collapsed: boolean; + displayName: string; + iconDataUri: string | null; + serverId: string | null; + activeWorkspaceSelection: { serverId: string; workspaceId: string } | null; + showShortcutBadges: boolean; + shortcutIndexByWorkspaceKey: Map; + parentGestureRef?: MutableRefObject; + onToggleCollapsed: () => void; + onWorkspacePress?: () => void; + onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; + onCreateWorktree?: (project: SidebarProjectEntry) => void; + drag: () => void; + isDragging: boolean; + dragHandleProps?: DraggableListDragHandleProps; + useNestable: boolean; + creatingWorkspaceIds: ReadonlySet; }) { const rowModel = useMemo( () => @@ -1264,15 +1261,22 @@ function ProjectBlock({ serverId, activeWorkspaceSelection, }), - [activeWorkspaceSelection, collapsed, project, serverId] - ) + [activeWorkspaceSelection, collapsed, project, serverId], + ); const renderWorkspaceRow = useCallback( - (item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => { + ( + item: SidebarWorkspaceEntry, + input?: { + drag?: () => void; + isDragging?: boolean; + dragHandleProps?: DraggableListDragHandleProps; + }, + ) => { const isSelected = Boolean(serverId) && activeWorkspaceSelection?.serverId === serverId && - activeWorkspaceSelection.workspaceId === item.workspaceId + activeWorkspaceSelection.workspaceId === item.workspaceId; return ( { if (!serverId) { - return + return; } - onWorkspacePress?.() - navigateToWorkspace(serverId, item.workspaceId) + onWorkspacePress?.(); + navigateToWorkspace(serverId, item.workspaceId); }} drag={input?.drag ?? (() => {})} isDragging={input?.isDragging ?? false} dragHandleProps={input?.dragHandleProps} /> - ) + ); }, [ activeWorkspaceSelection, @@ -1303,8 +1307,8 @@ function ProjectBlock({ serverId, shortcutIndexByWorkspaceKey, showShortcutBadges, - ] - ) + ], + ); const renderWorkspace = useCallback( ({ @@ -1317,21 +1321,21 @@ function ProjectBlock({ drag: workspaceDrag, isDragging: isActive, dragHandleProps: workspaceDragHandleProps, - }) + }); }, - [renderWorkspaceRow] - ) + [renderWorkspaceRow], + ); const handleWorkspaceDragEnd = useCallback( (workspaces: SidebarWorkspaceEntry[]) => { - onWorkspaceReorder(project.projectKey, workspaces) + onWorkspaceReorder(project.projectKey, workspaces); }, - [onWorkspaceReorder, project.projectKey] - ) + [onWorkspaceReorder, project.projectKey], + ); return ( - {rowModel.kind === 'workspace_link' ? ( + {rowModel.kind === "workspace_link" ? ( { if (!serverId) { - return + return; } - onWorkspacePress?.() - navigateToWorkspace(serverId, rowModel.workspace.workspaceId) + onWorkspacePress?.(); + navigateToWorkspace(serverId, rowModel.workspace.workspaceId); }} onCreateWorktree={ - rowModel.trailingAction === 'new_worktree' && onCreateWorktree + rowModel.trailingAction === "new_worktree" && onCreateWorktree ? () => onCreateWorktree(project) : undefined } @@ -1366,7 +1370,7 @@ function ProjectBlock({ chevron={rowModel.chevron} onPress={onToggleCollapsed} onCreateWorktree={ - rowModel.trailingAction === 'new_worktree' && onCreateWorktree + rowModel.trailingAction === "new_worktree" && onCreateWorktree ? () => onCreateWorktree(project) : undefined } @@ -1393,7 +1397,7 @@ function ProjectBlock({ )} - ) + ); } export function SidebarWorkspaceList({ @@ -1409,61 +1413,64 @@ export function SidebarWorkspaceList({ listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { - const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const isNative = Platform.OS !== 'web' - const pathname = usePathname() - const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection() - const toast = useToast() - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces) - const [creatingProjectKey, setCreatingProjectKey] = useState(null) - const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()) + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isNative = Platform.OS !== "web"; + const pathname = usePathname(); + const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); + const toast = useToast(); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const [creatingProjectKey, setCreatingProjectKey] = useState(null); + const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( - new Map() - ) - const isDesktopApp = getIsDesktop() - const altDown = useKeyboardShortcutsStore((state) => state.altDown) - const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown) - const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown) + new Map(), + ); + const isDesktopApp = getIsDesktop(); + const altDown = useKeyboardShortcutsStore((state) => state.altDown); + const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown); + const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown); - const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder) - const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder) - const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder) - const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder) + const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); + const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); + const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); + const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); - const isWorkspaceRoute = useMemo(() => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), [pathname]) - const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null + const isWorkspaceRoute = useMemo( + () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), + [pathname], + ); + const effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null; const projectIconRequests = useMemo(() => { if (!serverId) { - return [] + return []; } - const unique = new Map() + const unique = new Map(); for (const project of projects) { - const cwd = project.iconWorkingDir.trim() + const cwd = project.iconWorkingDir.trim(); if (!cwd) { - continue + continue; } - unique.set(`${serverId}:${cwd}`, { serverId, cwd }) + unique.set(`${serverId}:${cwd}`, { serverId, cwd }); } - return Array.from(unique.values()) - }, [projects, serverId]) + return Array.from(unique.values()); + }, [projects, serverId]); const projectIconQueries = useQueries({ queries: projectIconRequests.map((request) => ({ queryKey: projectIconQueryKey(request.serverId, request.cwd), queryFn: async () => { - const client = getHostRuntimeStore().getClient(request.serverId) + const client = getHostRuntimeStore().getClient(request.serverId); if (!client) { - return null + return null; } - const result = await client.requestProjectIcon(request.cwd) - return result.icon + const result = await client.requestProjectIcon(request.cwd); + return result.icon; }, select: toProjectIconDataUri, enabled: Boolean( getHostRuntimeStore().getClient(request.serverId) && - isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && - request.cwd + isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && + request.cwd, ), staleTime: Infinity, gcTime: 1000 * 60 * 60, @@ -1471,94 +1478,94 @@ export function SidebarWorkspaceList({ refetchOnWindowFocus: false, refetchOnReconnect: false, })), - }) + }); const projectIconByProjectKey = useMemo(() => { - const iconByServerAndCwd = new Map() + const iconByServerAndCwd = new Map(); for (let index = 0; index < projectIconRequests.length; index += 1) { - const request = projectIconRequests[index] + const request = projectIconRequests[index]; if (!request) { - continue + continue; } iconByServerAndCwd.set( `${request.serverId}:${request.cwd}`, - projectIconQueries[index]?.data ?? null - ) + projectIconQueries[index]?.data ?? null, + ); } - const byProject = new Map() + const byProject = new Map(); for (const project of projects) { - const cwd = project.iconWorkingDir.trim() + const cwd = project.iconWorkingDir.trim(); if (!cwd || !serverId) { - byProject.set(project.projectKey, null) - continue + byProject.set(project.projectKey, null); + continue; } - byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null) + byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null); } - return byProject - }, [projectIconQueries, projectIconRequests, projects, serverId]) + return byProject; + }, [projectIconQueries, projectIconRequests, projects, serverId]); useEffect(() => { return () => { for (const timeout of creatingWorkspaceTimeoutsRef.current.values()) { - clearTimeout(timeout) + clearTimeout(timeout); } - creatingWorkspaceTimeoutsRef.current.clear() - } - }, []) + creatingWorkspaceTimeoutsRef.current.clear(); + }; + }, []); useEffect(() => { if (creatingWorkspaceIds.size === 0) { - return + return; } - const visibleWorkspaceIds = new Set() + const visibleWorkspaceIds = new Set(); for (const project of projects) { for (const workspace of project.workspaces) { - visibleWorkspaceIds.add(workspace.workspaceId) + visibleWorkspaceIds.add(workspace.workspaceId); } } const removedWorkspaceIds = Array.from(creatingWorkspaceIds).filter( - (workspaceId) => !visibleWorkspaceIds.has(workspaceId) - ) + (workspaceId) => !visibleWorkspaceIds.has(workspaceId), + ); if (removedWorkspaceIds.length === 0) { - return + return; } for (const workspaceId of removedWorkspaceIds) { - const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId) + const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId); if (timeout) { - clearTimeout(timeout) - creatingWorkspaceTimeoutsRef.current.delete(workspaceId) + clearTimeout(timeout); + creatingWorkspaceTimeoutsRef.current.delete(workspaceId); } } setCreatingWorkspaceIds((current) => { - const next = new Set(current) + const next = new Set(current); for (const workspaceId of removedWorkspaceIds) { - next.delete(workspaceId) + next.delete(workspaceId); } - return next - }) - }, [creatingWorkspaceIds, projects]) + return next; + }); + }, [creatingWorkspaceIds, projects]); const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { if (!serverId) { - return + return; } - const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey) - const currentProjectOrder = getProjectOrder(serverId) + const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey); + const currentProjectOrder = getProjectOrder(serverId); if ( !hasVisibleOrderChanged({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }) ) { - return + return; } setProjectOrder( @@ -1566,27 +1573,27 @@ export function SidebarWorkspaceList({ mergeWithRemainder({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, - }) - ) + }), + ); }, - [getProjectOrder, serverId, setProjectOrder] - ) + [getProjectOrder, serverId, setProjectOrder], + ); const handleWorkspaceReorder = useCallback( (projectKey: string, reorderedWorkspaces: SidebarWorkspaceEntry[]) => { if (!serverId) { - return + return; } - const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey) - const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey) + const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); + const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey); if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, }) ) { - return + return; } setWorkspaceOrder( @@ -1595,77 +1602,75 @@ export function SidebarWorkspaceList({ mergeWithRemainder({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, - }) - ) + }), + ); }, - [getWorkspaceOrder, serverId, setWorkspaceOrder] - ) + [getWorkspaceOrder, serverId, setWorkspaceOrder], + ); const handleCreateWorktree = useCallback( async (project: SidebarProjectEntry) => { - if (!serverId || project.projectKind !== 'git') { - return + if (!serverId || project.projectKind !== "git") { + return; } if (creatingProjectKey) { - return + return; } - onSetProjectCollapsed(project.projectKey, false) - setCreatingProjectKey(project.projectKey) + onSetProjectCollapsed(project.projectKey, false); + setCreatingProjectKey(project.projectKey); try { - const client = getHostRuntimeStore().getClient(serverId) + const client = getHostRuntimeStore().getClient(serverId); if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) { - throw new Error('Host is not connected') + throw new Error("Host is not connected"); } const payload = await client.createPaseoWorktree({ cwd: project.iconWorkingDir, worktreeSlug: createNameId(), - }) + }); if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? 'Failed to create worktree') + throw new Error(payload.error ?? "Failed to create worktree"); } - const workspace = payload.workspace - mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(workspace)]) + const workspace = payload.workspace; + mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(workspace)]); setCreatingWorkspaceIds((current) => { - const next = new Set(current) - next.add(workspace.id) - return next - }) - const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspace.id) + const next = new Set(current); + next.add(workspace.id); + return next; + }); + const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspace.id); if (existingTimeout) { - clearTimeout(existingTimeout) + clearTimeout(existingTimeout); } creatingWorkspaceTimeoutsRef.current.set( workspace.id, setTimeout(() => { - creatingWorkspaceTimeoutsRef.current.delete(workspace.id) + creatingWorkspaceTimeoutsRef.current.delete(workspace.id); setCreatingWorkspaceIds((current) => { if (!current.has(workspace.id)) { - return current + return current; } - const next = new Set(current) - next.delete(workspace.id) - return next - }) - }, 3000) - ) - onWorkspacePress?.() + const next = new Set(current); + next.delete(workspace.id); + return next; + }); + }, 3000), + ); + onWorkspacePress?.(); router.navigate( prepareWorkspaceTab({ serverId, workspaceId: workspace.id, - target: { kind: 'draft', draftId: 'new' }, - }) as any - ) + target: { kind: "draft", draftId: "new" }, + }) as any, + ); } catch (error) { - toast.error( - error instanceof Error ? error.message : String(error) - ) + toast.error(error instanceof Error ? error.message : String(error)); } finally { - setCreatingProjectKey((current) => (current === project.projectKey ? null : current)) + setCreatingProjectKey((current) => (current === project.projectKey ? null : current)); } }, - [creatingProjectKey, mergeWorkspaces, onSetProjectCollapsed, onWorkspacePress, serverId, toast] - ) + [creatingProjectKey, mergeWorkspaces, onSetProjectCollapsed, onWorkspacePress, serverId, toast], + ); const renderProject = useCallback( ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => { @@ -1690,7 +1695,7 @@ export function SidebarWorkspaceList({ useNestable={isNative} creatingWorkspaceIds={creatingWorkspaceIds} /> - ) + ); }, [ collapsedProjectKeys, @@ -1706,8 +1711,8 @@ export function SidebarWorkspaceList({ showShortcutBadges, isNative, creatingWorkspaceIds, - ] - ) + ], + ); const content = ( <> @@ -1729,7 +1734,7 @@ export function SidebarWorkspaceList({ )} {listFooterComponent} - ) + ); return ( @@ -1738,7 +1743,6 @@ export function SidebarWorkspaceList({ style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} - testID="sidebar-project-workspace-list-scroll" > {content} @@ -1748,14 +1752,13 @@ export function SidebarWorkspaceList({ style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} - testID="sidebar-project-workspace-list-scroll" > {content} )} - ) + ); } const styles = StyleSheet.create((theme) => ({ @@ -1771,7 +1774,7 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[4], }, projectListContainer: { - width: '100%', + width: "100%", }, projectBlock: { marginBottom: theme.spacing[1], @@ -1784,7 +1787,7 @@ const styles = StyleSheet.create((theme) => ({ }, emptyText: { color: theme.colors.foregroundMuted, - textAlign: 'center', + textAlign: "center", marginTop: theme.spacing[8], marginHorizontal: theme.spacing[2], }, @@ -1794,9 +1797,9 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[1], - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", gap: theme.spacing[2], }, projectRowHovered: { @@ -1812,45 +1815,45 @@ const styles = StyleSheet.create((theme) => ({ transform: [{ scale: 1.02 }], zIndex: 3, elevation: 4, - shadowColor: '#000', + shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 8, shadowOffset: { width: 0, height: 4 }, }, projectRowLeft: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, projectTitleGroup: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, }, projectIcon: { - width: '100%', - height: '100%', + width: "100%", + height: "100%", borderRadius: theme.borderRadius.sm, }, projectLeadingVisualSlot: { width: theme.iconSize.md, height: theme.iconSize.md, flexShrink: 0, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, projectIconFallback: { - width: '100%', - height: '100%', + width: "100%", + height: "100%", borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, projectIconFallbackText: { color: theme.colors.foregroundMuted, @@ -1863,8 +1866,8 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 1, }, projectActionButton: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], @@ -1882,8 +1885,8 @@ const styles = StyleSheet.create((theme) => ({ width: 24, height: 24, borderRadius: theme.borderRadius.md, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", flexShrink: 0, }, projectIconActionButtonHovered: { @@ -1895,8 +1898,8 @@ const styles = StyleSheet.create((theme) => ({ projectTrailingControlSlot: { width: 24, height: 24, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", flexShrink: 0, }, projectActionTooltipText: { @@ -1909,21 +1912,21 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", gap: theme.spacing[2], }, workspaceRowLeft: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, workspaceRowRight: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], flexShrink: 0, }, @@ -1940,7 +1943,7 @@ const styles = StyleSheet.create((theme) => ({ transform: [{ scale: 1.02 }], zIndex: 3, elevation: 4, - shadowColor: '#000', + shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 8, shadowOffset: { width: 0, height: 4 }, @@ -1949,15 +1952,15 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface1, }, workspaceRowContainer: { - position: 'relative', + position: "relative", }, workspaceStatusDot: { width: 14, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, - alignItems: 'flex-start', - justifyContent: 'center', + alignItems: "flex-start", + justifyContent: "center", }, workspaceStatusDotFill: { width: 7, @@ -1968,16 +1971,16 @@ const styles = StyleSheet.create((theme) => ({ ...StyleSheet.absoluteFillObject, borderRadius: theme.borderRadius.lg, backgroundColor: `${theme.colors.surface0}cc`, - alignItems: 'center', - justifyContent: 'center', - flexDirection: 'row', + alignItems: "center", + justifyContent: "center", + flexDirection: "row", gap: theme.spacing[2], zIndex: 1, }, workspaceArchivingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, - fontWeight: '600', + fontWeight: "600", }, workspaceBranchText: { color: theme.colors.foreground, @@ -1999,8 +2002,8 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, diffStatRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: 6, flexShrink: 0, }, @@ -2026,8 +2029,8 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 18, height: 18, paddingHorizontal: theme.spacing[1], - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.surface2, @@ -2040,4 +2043,4 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.medium, lineHeight: 14, }, -})) +})); diff --git a/packages/app/src/components/sortable-inline-list.tsx b/packages/app/src/components/sortable-inline-list.tsx index 6e2b2316e..fb0bf2f62 100644 --- a/packages/app/src/components/sortable-inline-list.tsx +++ b/packages/app/src/components/sortable-inline-list.tsx @@ -5,4 +5,3 @@ // Metro's platform-specific extensions will pick the right one at runtime. export * from "./sortable-inline-list.native"; - diff --git a/packages/app/src/components/sortable-inline-list.web.tsx b/packages/app/src/components/sortable-inline-list.web.tsx index f8ac76cdd..6a1061fa6 100644 --- a/packages/app/src/components/sortable-inline-list.web.tsx +++ b/packages/app/src/components/sortable-inline-list.web.tsx @@ -66,7 +66,7 @@ function SortableItem({ const baseTransform = externalDndContext ? undefined : CSS.Transform.toString( - transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform + transform && isDragging ? { ...transform, scaleX: 1, scaleY: 1 } : transform, ); const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : ""; const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" "); @@ -87,9 +87,7 @@ function SortableItem({ ? { attributes: attributes as unknown as Record, listeners: listeners as unknown as Record, - setActivatorNodeRef: setActivatorNodeRef as unknown as ( - node: unknown - ) => void, + setActivatorNodeRef: setActivatorNodeRef as unknown as (node: unknown) => void, } : undefined, }; @@ -132,7 +130,7 @@ export function SortableInlineList({ }): ReactElement { const [activeId, setActiveId] = useState(null); const [dragItems, setDragItems] = useState(null); - const items = externalDndContext ? data : dragItems ?? data; + const items = externalDndContext ? data : (dragItems ?? data); const sensors = useSensors( useSensor(PointerSensor, { @@ -142,7 +140,7 @@ export function SortableInlineList({ }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, - }) + }), ); const handleDragStart = useCallback( @@ -154,7 +152,7 @@ export function SortableInlineList({ setActiveId(String(event.active.id)); onDragBegin?.(); }, - [data, disabled, onDragBegin] + [data, disabled, onDragBegin], ); const handleDragEnd = useCallback( @@ -169,21 +167,16 @@ export function SortableInlineList({ } if (over && active.id !== over.id) { - const oldIndex = items.findIndex( - (item, i) => keyExtractor(item, i) === active.id - ); - const newIndex = items.findIndex( - (item, i) => keyExtractor(item, i) === over.id - ); + const oldIndex = items.findIndex((item, i) => keyExtractor(item, i) === active.id); + const newIndex = items.findIndex((item, i) => keyExtractor(item, i) === over.id); if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) { const newItems = arrayMove(items, oldIndex, newIndex); onDragEnd?.(newItems); } } - }, - [disabled, items, keyExtractor, onDragEnd] + [disabled, items, keyExtractor, onDragEnd], ); const ids = items.map((item, index) => keyExtractor(item, index)); diff --git a/packages/app/src/components/split-container-pane-focus.test.ts b/packages/app/src/components/split-container-pane-focus.test.ts index f8c7efdd1..7e5f4541b 100644 --- a/packages/app/src/components/split-container-pane-focus.test.ts +++ b/packages/app/src/components/split-container-pane-focus.test.ts @@ -5,13 +5,13 @@ describe("shouldFocusPaneFromEventTarget", () => { it("returns false for links and buttons", () => { expect( shouldFocusPaneFromEventTarget({ - closest: () => ({ tagName: "A" } as Element), - } as unknown as EventTarget) + closest: () => ({ tagName: "A" }) as Element, + } as unknown as EventTarget), ).toBe(false); expect( shouldFocusPaneFromEventTarget({ - closest: () => ({ tagName: "BUTTON" } as Element), - } as unknown as EventTarget) + closest: () => ({ tagName: "BUTTON" }) as Element, + } as unknown as EventTarget), ).toBe(false); }); @@ -19,7 +19,7 @@ describe("shouldFocusPaneFromEventTarget", () => { expect( shouldFocusPaneFromEventTarget({ closest: () => null, - } as unknown as EventTarget) + } as unknown as EventTarget), ).toBe(true); expect(shouldFocusPaneFromEventTarget(null)).toBe(true); }); diff --git a/packages/app/src/components/split-container-tab-drop-preview.test.ts b/packages/app/src/components/split-container-tab-drop-preview.test.ts index 87e5666c8..f4db399ea 100644 --- a/packages/app/src/components/split-container-tab-drop-preview.test.ts +++ b/packages/app/src/components/split-container-tab-drop-preview.test.ts @@ -27,7 +27,7 @@ describe("computeTabDropPreview", () => { targetTabs, activeRect: { left: 180, width: 40 }, overRect: { left: 200, width: 100 }, - }) + }), ).toEqual({ paneId: "target", insertionIndex: 2, @@ -45,7 +45,7 @@ describe("computeTabDropPreview", () => { targetTabs, activeRect: { left: 280, width: 40 }, overRect: { left: 200, width: 100 }, - }) + }), ).toEqual({ paneId: "target", insertionIndex: 3, @@ -63,7 +63,7 @@ describe("computeTabDropPreview", () => { targetTabs, activeRect: { left: 460, width: 40 }, overRect: { left: 400, width: 100 }, - }) + }), ).toEqual({ paneId: "pane", insertionIndex: 3, diff --git a/packages/app/src/components/split-container-tab-drop-preview.ts b/packages/app/src/components/split-container-tab-drop-preview.ts index 4abfd724d..b9785bb52 100644 --- a/packages/app/src/components/split-container-tab-drop-preview.ts +++ b/packages/app/src/components/split-container-tab-drop-preview.ts @@ -22,9 +22,7 @@ interface ComputeTabDropPreviewInput { }; } -export function computeTabDropPreview( - input: ComputeTabDropPreviewInput -): TabDropPreview | null { +export function computeTabDropPreview(input: ComputeTabDropPreviewInput): TabDropPreview | null { const targetIndex = input.targetTabs.findIndex((tab) => tab.tabId === input.overTabId); if (targetIndex < 0 || input.overRect.width <= 0) { return null; diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 7fb82424e..b80663771 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,4 +1,14 @@ -import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from "react"; import { DndContext, DragOverlay, @@ -45,7 +55,12 @@ import { WorkspaceTabIcon, } from "@/screens/workspace/workspace-tab-presentation"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { useWorkspaceLayoutStore, type SplitNode, type SplitPane, type WorkspaceLayout } from "@/stores/workspace-layout-store"; +import { + useWorkspaceLayoutStore, + type SplitNode, + type SplitPane, + type WorkspaceLayout, +} from "@/stores/workspace-layout-store"; import type { WorkspaceTab } from "@/stores/workspace-tabs-store"; interface SplitContainerProps { @@ -65,7 +80,10 @@ interface SplitContainerProps { onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; - onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__" | "__new_tab_terminal__"; paneId?: string }) => void; + onSelectNewTabOption: (selection: { + optionId: "__new_tab_agent__" | "__new_tab_terminal__"; + paneId?: string; + }) => void; onNewTerminalTab: (input: { paneId?: string }) => void; newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__"; buildPaneContentModel: (input: { @@ -100,8 +118,7 @@ interface SplitPaneDropData { paneId: string; } -interface SplitNodeViewProps - extends Omit { +interface SplitNodeViewProps extends Omit { node: SplitNode; uiTabs: WorkspaceTab[]; focusedPaneId: string; @@ -135,14 +152,14 @@ interface SplitPaneViewProps const dropCollisionDetection: CollisionDetection = (args) => { const pointerHits = pointerWithin(args); const tabHits = pointerHits.filter( - (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab" + (entry) => entry.data?.droppableContainer.data.current?.kind === "workspace-tab", ); if (tabHits.length > 0) { return tabHits; } const paneHits = pointerHits.filter( - (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop" + (entry) => entry.data?.droppableContainer.data.current?.kind === "split-pane-drop", ); if (paneHits.length > 0) { return paneHits; @@ -192,7 +209,7 @@ export function SplitContainer({ }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, - }) + }), ); const panesById = useMemo(() => collectPanesById(layout.root), [layout.root]); @@ -215,11 +232,7 @@ export function SplitContainer({ }, []); const updateDropPreview = useCallback( - ( - event: - | Pick - | Pick - ) => { + (event: Pick | Pick) => { const activeData = event.active.data.current as WorkspaceTabDragData | undefined; const overData = event.over?.data.current as | WorkspaceTabDragData @@ -268,7 +281,7 @@ export function SplitContainer({ left: overRect.left, width: overRect.width, }, - }) + }), ); return; } @@ -305,7 +318,7 @@ export function SplitContainer({ }), }); }, - [panesById, uiTabs] + [panesById, uiTabs], ); const handleDragEnd = useCallback( @@ -346,8 +359,15 @@ export function SplitContainer({ if (activeData.paneId === overData.paneId) { if (sourceIndex !== resolvedTabDropPreview.insertionIndex) { - const nextTabs = arrayMove(sourceTabs, sourceIndex, resolvedTabDropPreview.insertionIndex); - onReorderTabsInPane(activeData.paneId, nextTabs.map((tab) => tab.tabId)); + const nextTabs = arrayMove( + sourceTabs, + sourceIndex, + resolvedTabDropPreview.insertionIndex, + ); + onReorderTabsInPane( + activeData.paneId, + nextTabs.map((tab) => tab.tabId), + ); } setDropPreview(null); setTabDropPreview(null); @@ -383,7 +403,15 @@ export function SplitContainer({ setDropPreview(null); setTabDropPreview(null); }, - [dropPreview, onMoveTabToPane, onReorderTabsInPane, onSplitPane, panesById, tabDropPreview, uiTabs] + [ + dropPreview, + onMoveTabToPane, + onReorderTabsInPane, + onSplitPane, + panesById, + tabDropPreview, + uiTabs, + ], ); return ( @@ -492,8 +520,7 @@ function DragOverlayTabChipInner({ workspaceId={normalizedWorkspaceId} > {(presentation) => { - const label = - presentation.titleState === "loading" ? "Loading..." : presentation.label; + const label = presentation.titleState === "loading" ? "Loading..." : presentation.label; return ( {label} @@ -593,8 +617,9 @@ function SplitNodeView({ } const groupSizes = - useWorkspaceLayoutStore((state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id]) ?? - node.group.sizes; + useWorkspaceLayoutStore( + (state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id], + ) ?? node.group.sizes; return ( paneState.tabs.map((tab) => tab.descriptor), - [paneState.tabs] + [pane, uiTabs], ); + const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]); const activeTabDescriptor = paneState.activeTab?.descriptor ?? null; const desktopTabRowItems = useMemo( () => @@ -710,12 +732,7 @@ function SplitPaneView({ isCloseHovered: hoveredCloseTabKey === tab.key, isClosingTab: closingTabIds.has(tab.tabId), })), - [ - activeTabDescriptor?.key, - closingTabIds, - hoveredCloseTabKey, - paneTabs, - ] + [activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs], ); const paneContent = useMemo( () => @@ -726,11 +743,7 @@ function SplitPaneView({ tab: activeTabDescriptor, }) : null, - [ - activeTabDescriptor, - buildPaneContentModel, - pane.id, - ] + [activeTabDescriptor, buildPaneContentModel, pane.id], ); useEffect(() => { @@ -771,11 +784,7 @@ function SplitPaneView({ }, [onFocusPane, pane.id]); return ( - + { - onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId)); + onReorderTabsInPane( + pane.id, + nextTabs.map((tab) => tab.tabId), + ); }} onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })} onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })} externalDndContext activeDragTabId={activeDragTabId} - tabDropPreviewIndex={tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null} + tabDropPreviewIndex={ + tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null + } /> @@ -810,7 +824,7 @@ function SplitPaneView({ {paneContent ? ( ) : ( - renderPaneEmptyState?.() ?? null + (renderPaneEmptyState?.() ?? null) )} diff --git a/packages/app/src/components/split-drop-zone.tsx b/packages/app/src/components/split-drop-zone.tsx index d1b55857c..7963403dc 100644 --- a/packages/app/src/components/split-drop-zone.tsx +++ b/packages/app/src/components/split-drop-zone.tsx @@ -23,11 +23,7 @@ export function buildSplitDropZoneId(paneId: string): string { return `split-pane-drop:${paneId}`; } -export function SplitDropZone({ - paneId, - active, - preview, -}: SplitDropZoneProps) { +export function SplitDropZone({ paneId, active, preview }: SplitDropZoneProps) { const { theme } = useUnistyles(); const { setNodeRef } = useDroppable({ id: buildSplitDropZoneId(paneId), @@ -66,11 +62,7 @@ export function SplitDropZone({ } return ( - + {previewStyles ? ( <> @@ -89,10 +81,8 @@ export function resolveSplitDropPosition(input: { }): SplitDropZonePosition { const centerInsetX = input.width * ((1 - CENTER_RATIO) / 2); const centerInsetY = input.height * ((1 - CENTER_RATIO) / 2); - const insideCenterX = - input.x >= centerInsetX && input.x <= input.width - centerInsetX; - const insideCenterY = - input.y >= centerInsetY && input.y <= input.height - centerInsetY; + const insideCenterX = input.x >= centerInsetX && input.x <= input.width - centerInsetX; + const insideCenterY = input.y >= centerInsetY && input.y <= input.height - centerInsetY; if (insideCenterX && insideCenterY) { return "center"; diff --git a/packages/app/src/components/stream-strategy-native.tsx b/packages/app/src/components/stream-strategy-native.tsx index fa1337478..ac75da5b2 100644 --- a/packages/app/src/components/stream-strategy-native.tsx +++ b/packages/app/src/components/stream-strategy-native.tsx @@ -9,11 +9,7 @@ import { } from "react-native"; import type { StreamItem } from "@/types/stream"; import { useBottomAnchorController } from "./use-bottom-anchor-controller"; -import type { - StreamRenderInput, - StreamStrategy, - StreamViewportHandle, -} from "./stream-strategy"; +import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy"; import { createStreamStrategy, isNearBottomForStreamRenderStrategy, @@ -92,7 +88,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat strategy, isViewportSettling: isNativeViewportSettling, }), - [isNativeViewportSettling, strategy] + [isNativeViewportSettling, strategy], ); const scrollToBottom = useCallback( @@ -109,7 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }; onNearBottomChange(true); }, - [onNearBottomChange] + [onNearBottomChange], ); const bottomAnchorController = useBottomAnchorController({ @@ -159,7 +155,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const subscriptions = keyboardEvents.map((eventName) => Keyboard.addListener(eventName, () => { markNativeViewportSettling(); - }) + }), ); return () => { for (const subscription of subscriptions) { @@ -228,7 +224,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat }); } }, - [bottomAnchorController, onNearBottomChange, strategy] + [bottomAnchorController, onNearBottomChange, strategy], ); const handleListLayout = useCallback( @@ -257,7 +253,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat viewportHeight, }); }, - [bottomAnchorController, markNativeViewportSettling] + [bottomAnchorController, markNativeViewportSettling], ); const handleContentSizeChange = useCallback( @@ -275,7 +271,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat contentHeight: nextContentHeight, }); }, - [bottomAnchorController] + [bottomAnchorController], ); const renderItem = useCallback( @@ -283,7 +279,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat const rendered = renderers.renderHistoryMountedRow(item, index, historyRows); return rendered ? {rendered} : null; }, - [historyRows, renderers] + [historyRows, renderers], ); const liveHeaderContent = useMemo(() => { @@ -336,12 +332,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat export function createNativeStreamStrategy(): StreamStrategy { const strategy = createStreamStrategy({ - render: (renderInput) => ( - - ), + render: (renderInput) => , orderTailReverse: true, orderHeadReverse: true, assistantTurnTraversalStep: 1, diff --git a/packages/app/src/components/stream-strategy-web.tsx b/packages/app/src/components/stream-strategy-web.tsx index 1b3d4c8c3..3768bb898 100644 --- a/packages/app/src/components/stream-strategy-web.tsx +++ b/packages/app/src/components/stream-strategy-web.tsx @@ -7,23 +7,23 @@ import { useMemo, useRef, useState, -} from 'react' -import { measureElement as measureVirtualElement, useVirtualizer } from '@tanstack/react-virtual' -import { estimateStreamItemHeight } from './agent-stream-web-virtualization' -import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from './stream-strategy' -import { createStreamStrategy } from './stream-strategy' +} from "react"; +import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual"; +import { estimateStreamItemHeight } from "./agent-stream-web-virtualization"; +import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy"; +import { createStreamStrategy } from "./stream-strategy"; type CreateWebStreamStrategyInput = { - isMobileBreakpoint: boolean -} + isMobileBreakpoint: boolean; +}; -type ScrollBehaviorLike = 'auto' | 'smooth' +type ScrollBehaviorLike = "auto" | "smooth"; -const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200 -const USER_SCROLL_DELTA_EPSILON = 1 -const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64 -const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1 -const WEB_STREAM_SCROLLBAR_STYLE_ID = 'web-stream-viewport-scrollbar-style' +const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200; +const USER_SCROLL_DELTA_EPSILON = 1; +const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64; +const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1; +const WEB_STREAM_SCROLLBAR_STYLE_ID = "web-stream-viewport-scrollbar-style"; const WEB_STREAM_SCROLLBAR_STYLE = ` #agent-chat-scroll-web-dom-scroll, #agent-chat-scroll-web-dom-virtualized { @@ -37,73 +37,73 @@ const WEB_STREAM_SCROLLBAR_STYLE = ` width: 0; height: 0; } -` +`; function logWebStickyBottom(_event: string, _details: Record): void { // Intentionally disabled: this path is too noisy during voice debugging. } function getDebugNow(): number | null { - if (typeof performance !== 'undefined' && typeof performance.now === 'function') { - return Number(performance.now().toFixed(3)) + if (typeof performance !== "undefined" && typeof performance.now === "function") { + return Number(performance.now().toFixed(3)); } - return null + return null; } function isScrollContainerNearBottom( - scrollContainer: Pick, - thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX + scrollContainer: Pick, + thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX, ): boolean { const threshold = Number.isFinite(thresholdPx) ? Math.max(0, thresholdPx) - : AUTO_SCROLL_BOTTOM_THRESHOLD_PX - const { scrollTop, clientHeight, scrollHeight } = scrollContainer + : AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + const { scrollTop, clientHeight, scrollHeight } = scrollContainer; if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) { - return true + return true; } - const distanceFromBottom = scrollHeight - clientHeight - scrollTop - return distanceFromBottom <= threshold + const distanceFromBottom = scrollHeight - clientHeight - scrollTop; + return distanceFromBottom <= threshold; } function isScrollContainerAtBottom( - scrollContainer: Pick + scrollContainer: Pick, ): boolean { - return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX) + return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX); } function scrollElementToBottom( scrollContainer: HTMLElement, - behavior: ScrollBehaviorLike = 'auto' + behavior: ScrollBehaviorLike = "auto", ): void { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior, - }) + }); } function syncNearBottom( scrollContainer: HTMLElement | null, - onNearBottomChange: (value: boolean) => void + onNearBottomChange: (value: boolean) => void, ): boolean { if (!scrollContainer) { - onNearBottomChange(true) - return true + onNearBottomChange(true); + return true; } - const nextValue = isScrollContainerNearBottom(scrollContainer) - onNearBottomChange(nextValue) - return nextValue + const nextValue = isScrollContainerNearBottom(scrollContainer); + onNearBottomChange(nextValue); + return nextValue; } function getScrollContainerDistanceFromBottom( - scrollContainer: Pick + scrollContainer: Pick, ): number { - return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop + return scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop; } function isScrollContainerOverscrolledPastBottom( - scrollContainer: Pick + scrollContainer: Pick, ): boolean { - return getScrollContainerDistanceFromBottom(scrollContainer) < 0 + return getScrollContainerDistanceFromBottom(scrollContainer) < 0; } function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { @@ -118,63 +118,63 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool onNearBottomChange, scrollEnabled, isMobileBreakpoint, - } = props + } = props; const { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics } = - require('./web-desktop-scrollbar') as typeof import('./web-desktop-scrollbar') - const scrollContainerRef = useRef(null) - const contentRef = useRef(null) - const [followOutput, setFollowOutputr] = useState(true) + require("./web-desktop-scrollbar") as typeof import("./web-desktop-scrollbar"); + const scrollContainerRef = useRef(null); + const contentRef = useRef(null); + const [followOutput, setFollowOutputr] = useState(true); const setFollowOutput = (value: boolean) => { - setFollowOutputr(value) - return value - } - const followOutputRef = useRef(followOutput) - const lastKnownScrollTopRef = useRef(0) + setFollowOutputr(value); + return value; + }; + const followOutputRef = useRef(followOutput); + const lastKnownScrollTopRef = useRef(0); const lastLoggedMetricsRef = useRef<{ - scrollTop: number - clientWidth: number - clientHeight: number - scrollWidth: number - scrollHeight: number - } | null>(null) - const pendingUserScrollUpIntentRef = useRef(false) - const isPointerScrollActiveRef = useRef(false) - const lastTouchClientYRef = useRef(null) - const pendingAutoScrollFrameRef = useRef(null) - const pendingAutoScrollTimeoutRef = useRef(null) - const streamScrollbarMetrics = useWebDesktopScrollbarMetrics() - const showDesktopWebScrollbar = !isMobileBreakpoint - const shouldUseVirtualizer = segments.historyVirtualized.length > 0 + scrollTop: number; + clientWidth: number; + clientHeight: number; + scrollWidth: number; + scrollHeight: number; + } | null>(null); + const pendingUserScrollUpIntentRef = useRef(false); + const isPointerScrollActiveRef = useRef(false); + const lastTouchClientYRef = useRef(null); + const pendingAutoScrollFrameRef = useRef(null); + const pendingAutoScrollTimeoutRef = useRef(null); + const streamScrollbarMetrics = useWebDesktopScrollbarMetrics(); + const showDesktopWebScrollbar = !isMobileBreakpoint; + const shouldUseVirtualizer = segments.historyVirtualized.length > 0; const { renderHistoryVirtualizedRow, renderHistoryMountedRow, renderLiveHeadRow, renderLiveAuxiliary, - } = renderers + } = renderers; - followOutputRef.current = followOutput + followOutputRef.current = followOutput; - const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId - const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady + const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId; + const isActivationReady = routeBottomAnchorRequest === null || isAuthoritativeHistoryReady; const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, getScrollElement: () => scrollContainerRef.current, getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { - const row = segments.historyVirtualized[index] - return row ? estimateStreamItemHeight(row) : 120 + const row = segments.historyVirtualized[index]; + return row ? estimateStreamItemHeight(row) : 120; }, measureElement: measureVirtualElement, useAnimationFrameWithResizeObserver: true, overscan: 8, - }) + }); useEffect(() => { rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (_item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0 - const scrollOffset = instance.scrollOffset ?? 0 - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight) - logWebStickyBottom('virtualizer_item_size_change', { + const viewportHeight = instance.scrollRect?.height ?? 0; + const scrollOffset = instance.scrollOffset ?? 0; + const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); + logWebStickyBottom("virtualizer_item_size_change", { agentId: props.agentId, delta: _delta, itemIndex: _item.index, @@ -184,39 +184,39 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scrollOffset, totalSize: instance.getTotalSize(), remainingDistance, - }) - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX - } + }); + return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; + }; return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined - } - }, [rowVirtualizer]) - const virtualRows = rowVirtualizer.getVirtualItems() - const virtualTotalSize = rowVirtualizer.getTotalSize() + rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; + }; + }, [rowVirtualizer]); + const virtualRows = rowVirtualizer.getVirtualItems(); + const virtualTotalSize = rowVirtualizer.getTotalSize(); const cancelPendingStickToBottom = useCallback(() => { - const pendingFrame = pendingAutoScrollFrameRef.current + const pendingFrame = pendingAutoScrollFrameRef.current; if (pendingFrame !== null) { - pendingAutoScrollFrameRef.current = null - window.cancelAnimationFrame(pendingFrame) + pendingAutoScrollFrameRef.current = null; + window.cancelAnimationFrame(pendingFrame); } - const pendingTimeout = pendingAutoScrollTimeoutRef.current + const pendingTimeout = pendingAutoScrollTimeoutRef.current; if (pendingTimeout !== null) { - pendingAutoScrollTimeoutRef.current = null - window.clearTimeout(pendingTimeout) + pendingAutoScrollTimeoutRef.current = null; + window.clearTimeout(pendingTimeout); } - }, []) + }, []); const scrollMessagesToBottom = useCallback( - (behavior: ScrollBehaviorLike = 'auto') => { - const scrollContainer = scrollContainerRef.current + (behavior: ScrollBehaviorLike = "auto") => { + const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - return + return; } if (isScrollContainerOverscrolledPastBottom(scrollContainer)) { - return + return; } - logWebStickyBottom('viewport_scroll_to_bottom', { + logWebStickyBottom("viewport_scroll_to_bottom", { agentId: props.agentId, behavior, followOutput: followOutputRef.current, @@ -225,59 +225,56 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool clientHeight: scrollContainer.clientHeight, scrollWidth: scrollContainer.scrollWidth, scrollHeight: scrollContainer.scrollHeight, - }) - scrollElementToBottom(scrollContainer, behavior) - lastKnownScrollTopRef.current = scrollContainer.scrollTop - syncNearBottom(scrollContainer, onNearBottomChange) + }); + scrollElementToBottom(scrollContainer, behavior); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + syncNearBottom(scrollContainer, onNearBottomChange); }, - [onNearBottomChange, props.agentId] - ) + [onNearBottomChange, props.agentId], + ); - const scheduleStickToBottom = useCallback( - () => { - const scrollContainer = scrollContainerRef.current - if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) { - return + const scheduleStickToBottom = useCallback(() => { + const scrollContainer = scrollContainerRef.current; + if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) { + return; + } + if (pendingAutoScrollFrameRef.current !== null) { + return; + } + logWebStickyBottom("viewport_schedule_stick_to_bottom", { + agentId: props.agentId, + followOutput: followOutputRef.current, + scrollTop: scrollContainer?.scrollTop ?? null, + clientWidth: scrollContainer?.clientWidth ?? null, + clientHeight: scrollContainer?.clientHeight ?? null, + scrollWidth: scrollContainer?.scrollWidth ?? null, + scrollHeight: scrollContainer?.scrollHeight ?? null, + }); + pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { + pendingAutoScrollFrameRef.current = null; + if (!followOutputRef.current) { + return; } - if (pendingAutoScrollFrameRef.current !== null) { - return - } - logWebStickyBottom('viewport_schedule_stick_to_bottom', { - agentId: props.agentId, - followOutput: followOutputRef.current, - scrollTop: scrollContainer?.scrollTop ?? null, - clientWidth: scrollContainer?.clientWidth ?? null, - clientHeight: scrollContainer?.clientHeight ?? null, - scrollWidth: scrollContainer?.scrollWidth ?? null, - scrollHeight: scrollContainer?.scrollHeight ?? null, - }) - pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => { - pendingAutoScrollFrameRef.current = null - if (!followOutputRef.current) { - return - } - scrollMessagesToBottom('auto') - }) - }, - [props.agentId, scrollMessagesToBottom] - ) + scrollMessagesToBottom("auto"); + }); + }, [props.agentId, scrollMessagesToBottom]); const forceStickToBottom = useCallback(() => { - cancelPendingStickToBottom() - scrollMessagesToBottom('auto') - scheduleStickToBottom() - }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]) + cancelPendingStickToBottom(); + scrollMessagesToBottom("auto"); + scheduleStickToBottom(); + }, [cancelPendingStickToBottom, scheduleStickToBottom, scrollMessagesToBottom]); const updateScrollMetrics = useCallback(() => { - const scrollContainer = scrollContainerRef.current + const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - onNearBottomChange(true) - return + onNearBottomChange(true); + return; } streamScrollbarMetrics.onContentSizeChange( scrollContainer.clientWidth, - scrollContainer.scrollHeight - ) + scrollContainer.scrollHeight, + ); streamScrollbarMetrics.onLayout({ nativeEvent: { layout: { @@ -287,7 +284,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool y: 0, }, }, - } as never) + } as never); streamScrollbarMetrics.onScroll({ nativeEvent: { contentOffset: { x: 0, y: scrollContainer.scrollTop }, @@ -300,140 +297,142 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool height: scrollContainer.clientHeight, }, }, - } as never) - syncNearBottom(scrollContainer, onNearBottomChange) + } as never); + syncNearBottom(scrollContainer, onNearBottomChange); const currentMetrics = { scrollTop: scrollContainer.scrollTop, clientWidth: scrollContainer.clientWidth, clientHeight: scrollContainer.clientHeight, scrollWidth: scrollContainer.scrollWidth, scrollHeight: scrollContainer.scrollHeight, - } - const previousMetrics = lastLoggedMetricsRef.current + }; + const previousMetrics = lastLoggedMetricsRef.current; const shouldLog = !previousMetrics || previousMetrics.scrollTop !== currentMetrics.scrollTop || previousMetrics.clientWidth !== currentMetrics.clientWidth || previousMetrics.clientHeight !== currentMetrics.clientHeight || previousMetrics.scrollWidth !== currentMetrics.scrollWidth || - previousMetrics.scrollHeight !== currentMetrics.scrollHeight + previousMetrics.scrollHeight !== currentMetrics.scrollHeight; if (shouldLog) { - lastLoggedMetricsRef.current = currentMetrics - logWebStickyBottom('viewport_metrics_updated', { + lastLoggedMetricsRef.current = currentMetrics; + logWebStickyBottom("viewport_metrics_updated", { agentId: props.agentId, followOutput: followOutputRef.current, distanceFromBottom: getScrollContainerDistanceFromBottom(scrollContainer), ...currentMetrics, - }) + }); } - }, [onNearBottomChange, props.agentId, streamScrollbarMetrics]) + }, [onNearBottomChange, props.agentId, streamScrollbarMetrics]); const handleDomScroll = useCallback(() => { - const scrollContainer = scrollContainerRef.current + const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - return + return; } - const currentScrollTop = scrollContainer.scrollTop - const isAtBottom = isScrollContainerAtBottom(scrollContainer) - const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON + const currentScrollTop = scrollContainer.scrollTop; + const isAtBottom = isScrollContainerAtBottom(scrollContainer); + const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON; if (!followOutputRef.current && isAtBottom) { - setFollowOutput(true) - pendingUserScrollUpIntentRef.current = false + setFollowOutput(true); + pendingUserScrollUpIntentRef.current = false; } else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) { if (scrolledUp) { - cancelPendingStickToBottom() - setFollowOutput(false) + cancelPendingStickToBottom(); + setFollowOutput(false); } - pendingUserScrollUpIntentRef.current = false + pendingUserScrollUpIntentRef.current = false; } else if (followOutputRef.current && isPointerScrollActiveRef.current) { if (scrolledUp) { - cancelPendingStickToBottom() - setFollowOutput(false) + cancelPendingStickToBottom(); + setFollowOutput(false); } } - lastKnownScrollTopRef.current = currentScrollTop - logWebStickyBottom('viewport_dom_scroll', { + lastKnownScrollTopRef.current = currentScrollTop; + logWebStickyBottom("viewport_dom_scroll", { agentId: props.agentId, now: getDebugNow(), scrollTop: currentScrollTop, clientHeight: scrollContainer.clientHeight, scrollHeight: scrollContainer.scrollHeight, activeElementTag: - typeof document !== 'undefined' ? document.activeElement?.tagName?.toLowerCase() ?? null : null, - activeElementRole: - typeof document !== 'undefined' - ? document.activeElement?.getAttribute?.('aria-label') ?? null + typeof document !== "undefined" + ? (document.activeElement?.tagName?.toLowerCase() ?? null) : null, - }) - updateScrollMetrics() - }, [cancelPendingStickToBottom, updateScrollMetrics]) + activeElementRole: + typeof document !== "undefined" + ? (document.activeElement?.getAttribute?.("aria-label") ?? null) + : null, + }); + updateScrollMetrics(); + }, [cancelPendingStickToBottom, updateScrollMetrics]); useLayoutEffect(() => { if (!isActivationReady) { - return + return; } - setFollowOutput(true) - forceStickToBottom() + setFollowOutput(true); + forceStickToBottom(); const timeout = window.setTimeout(() => { if (!followOutputRef.current) { - return + return; } - const scrollContainer = scrollContainerRef.current + const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - return + return; } if (isScrollContainerNearBottom(scrollContainer)) { - return + return; } - scheduleStickToBottom() - }, WEB_BOTTOM_SETTLE_TIMEOUT_MS) + scheduleStickToBottom(); + }, WEB_BOTTOM_SETTLE_TIMEOUT_MS); return () => { - window.clearTimeout(timeout) - } - }, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]) + window.clearTimeout(timeout); + }; + }, [activationKey, forceStickToBottom, isActivationReady, scheduleStickToBottom]); useEffect(() => { if (!followOutputRef.current) { - return + return; } - scheduleStickToBottom() + scheduleStickToBottom(); }, [ scheduleStickToBottom, segments.historyMounted, segments.historyVirtualized, segments.liveHead, - ]) + ]); useEffect(() => { if (!followOutputRef.current || !shouldUseVirtualizer) { - return + return; } - scheduleStickToBottom() - }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]) + scheduleStickToBottom(); + }, [scheduleStickToBottom, shouldUseVirtualizer, virtualTotalSize]); useEffect(() => { - updateScrollMetrics() + updateScrollMetrics(); }, [ segments.historyMounted.length, segments.historyVirtualized.length, segments.liveHead.length, updateScrollMetrics, virtualTotalSize, - ]) + ]); useEffect(() => { - const scrollContainer = scrollContainerRef.current - const contentNode = contentRef.current - if (!scrollContainer || typeof ResizeObserver === 'undefined') { - return + const scrollContainer = scrollContainerRef.current; + const contentNode = contentRef.current; + if (!scrollContainer || typeof ResizeObserver === "undefined") { + return; } - updateScrollMetrics() + updateScrollMetrics(); const observer = new ResizeObserver(() => { - logWebStickyBottom('viewport_resize_observed', { + logWebStickyBottom("viewport_resize_observed", { agentId: props.agentId, followOutput: followOutputRef.current, scrollTop: scrollContainer.scrollTop, @@ -441,181 +440,183 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool clientHeight: scrollContainer.clientHeight, scrollWidth: scrollContainer.scrollWidth, scrollHeight: scrollContainer.scrollHeight, - }) - updateScrollMetrics() + }); + updateScrollMetrics(); if (!followOutputRef.current) { - return + return; } - scheduleStickToBottom() - }) - observer.observe(scrollContainer) + scheduleStickToBottom(); + }); + observer.observe(scrollContainer); if (contentNode) { - observer.observe(contentNode) + observer.observe(contentNode); } return () => { - observer.disconnect() - } - }, [props.agentId, scheduleStickToBottom, updateScrollMetrics]) + observer.disconnect(); + }; + }, [props.agentId, scheduleStickToBottom, updateScrollMetrics]); useEffect(() => { - const scrollContainer = scrollContainerRef.current + const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - return + return; } - const originalScrollTo = scrollContainer.scrollTo.bind(scrollContainer) + const originalScrollTo = scrollContainer.scrollTo.bind(scrollContainer); const scrollTopDescriptor = - Object.getOwnPropertyDescriptor(Object.getPrototypeOf(scrollContainer), 'scrollTop') ?? - Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop') - scrollContainer.scrollTo = ((...args: Parameters) => { - const firstArg = args[0] as ScrollToOptions | number | undefined + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(scrollContainer), "scrollTop") ?? + Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop"); + scrollContainer.scrollTo = ((...args: Parameters) => { + const firstArg = args[0] as ScrollToOptions | number | undefined; const target = - typeof firstArg === 'object' && firstArg !== null + typeof firstArg === "object" && firstArg !== null ? { top: firstArg.top ?? null, left: firstArg.left ?? null, behavior: firstArg.behavior ?? null, } : { - top: typeof args[1] === 'number' ? args[1] : null, - left: typeof firstArg === 'number' ? firstArg : null, + top: typeof args[1] === "number" ? args[1] : null, + left: typeof firstArg === "number" ? firstArg : null, behavior: null, - } - logWebStickyBottom('viewport_scroll_to_called', { + }; + logWebStickyBottom("viewport_scroll_to_called", { agentId: props.agentId, now: getDebugNow(), currentScrollTop: scrollContainer.scrollTop, target, stack: - typeof Error !== 'undefined' - ? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null + typeof Error !== "undefined" + ? (new Error().stack?.split("\n").slice(1, 6).join("\n") ?? null) : null, - }) - return originalScrollTo(...args) - }) as typeof scrollContainer.scrollTo + }); + return originalScrollTo(...args); + }) as typeof scrollContainer.scrollTo; if (scrollTopDescriptor?.get && scrollTopDescriptor?.set) { - Object.defineProperty(scrollContainer, 'scrollTop', { + Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, enumerable: scrollTopDescriptor.enumerable ?? false, get() { - return scrollTopDescriptor.get?.call(scrollContainer) + return scrollTopDescriptor.get?.call(scrollContainer); }, set(value: number) { - logWebStickyBottom('viewport_scroll_top_set', { + logWebStickyBottom("viewport_scroll_top_set", { agentId: props.agentId, now: getDebugNow(), currentScrollTop: scrollTopDescriptor.get?.call(scrollContainer) ?? null, nextScrollTop: value, stack: - typeof Error !== 'undefined' - ? new Error().stack?.split('\n').slice(1, 6).join('\n') ?? null + typeof Error !== "undefined" + ? (new Error().stack?.split("\n").slice(1, 6).join("\n") ?? null) : null, - }) - return scrollTopDescriptor.set?.call(scrollContainer, value) + }); + return scrollTopDescriptor.set?.call(scrollContainer, value); }, - }) + }); } const handleWheel = (event: WheelEvent) => { if (event.deltaY < 0) { - pendingUserScrollUpIntentRef.current = true - cancelPendingStickToBottom() + pendingUserScrollUpIntentRef.current = true; + cancelPendingStickToBottom(); } - } + }; const handlePointerDown = () => { - isPointerScrollActiveRef.current = true - } + isPointerScrollActiveRef.current = true; + }; const handlePointerUp = () => { - isPointerScrollActiveRef.current = false - } + isPointerScrollActiveRef.current = false; + }; const handleTouchStart = (event: TouchEvent) => { - const touch = event.touches[0] + const touch = event.touches[0]; if (!touch) { - return + return; } - lastTouchClientYRef.current = touch.clientY - } + lastTouchClientYRef.current = touch.clientY; + }; const handleTouchMove = (event: TouchEvent) => { - const touch = event.touches[0] + const touch = event.touches[0]; if (!touch) { - return + return; } - const previousTouchY = lastTouchClientYRef.current + const previousTouchY = lastTouchClientYRef.current; if (previousTouchY !== null && touch.clientY > previousTouchY + 1) { - pendingUserScrollUpIntentRef.current = true - cancelPendingStickToBottom() + pendingUserScrollUpIntentRef.current = true; + cancelPendingStickToBottom(); } - lastTouchClientYRef.current = touch.clientY - } + lastTouchClientYRef.current = touch.clientY; + }; const handleTouchEnd = () => { - lastTouchClientYRef.current = null - } + lastTouchClientYRef.current = null; + }; const handleSelectionChange = () => { const activeElement = - typeof document !== 'undefined' ? (document.activeElement as HTMLTextAreaElement | null) : null - logWebStickyBottom('document_selection_changed', { + typeof document !== "undefined" + ? (document.activeElement as HTMLTextAreaElement | null) + : null; + logWebStickyBottom("document_selection_changed", { agentId: props.agentId, now: getDebugNow(), activeElementTag: activeElement?.tagName?.toLowerCase() ?? null, - activeElementRole: activeElement?.getAttribute?.('aria-label') ?? null, + activeElementRole: activeElement?.getAttribute?.("aria-label") ?? null, selectionStart: - activeElement && typeof activeElement.selectionStart === 'number' + activeElement && typeof activeElement.selectionStart === "number" ? activeElement.selectionStart : null, selectionEnd: - activeElement && typeof activeElement.selectionEnd === 'number' + activeElement && typeof activeElement.selectionEnd === "number" ? activeElement.selectionEnd : null, scrollTop: scrollContainer.scrollTop, - }) - } + }); + }; - scrollContainer.addEventListener('scroll', handleDomScroll, { passive: true }) - scrollContainer.addEventListener('wheel', handleWheel, { passive: true }) - scrollContainer.addEventListener('pointerdown', handlePointerDown, { passive: true }) - scrollContainer.addEventListener('pointerup', handlePointerUp, { passive: true }) - scrollContainer.addEventListener('pointercancel', handlePointerUp, { passive: true }) - scrollContainer.addEventListener('touchstart', handleTouchStart, { passive: true }) - scrollContainer.addEventListener('touchmove', handleTouchMove, { passive: true }) - scrollContainer.addEventListener('touchend', handleTouchEnd, { passive: true }) - scrollContainer.addEventListener('touchcancel', handleTouchEnd, { passive: true }) - if (typeof document !== 'undefined') { - document.addEventListener('selectionchange', handleSelectionChange, { passive: true }) + scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true }); + scrollContainer.addEventListener("wheel", handleWheel, { passive: true }); + scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true }); + scrollContainer.addEventListener("pointerup", handlePointerUp, { passive: true }); + scrollContainer.addEventListener("pointercancel", handlePointerUp, { passive: true }); + scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true }); + scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true }); + scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true }); + scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true }); + if (typeof document !== "undefined") { + document.addEventListener("selectionchange", handleSelectionChange, { passive: true }); } return () => { - scrollContainer.removeEventListener('scroll', handleDomScroll) - scrollContainer.removeEventListener('wheel', handleWheel) - scrollContainer.removeEventListener('pointerdown', handlePointerDown) - scrollContainer.removeEventListener('pointerup', handlePointerUp) - scrollContainer.removeEventListener('pointercancel', handlePointerUp) - scrollContainer.removeEventListener('touchstart', handleTouchStart) - scrollContainer.removeEventListener('touchmove', handleTouchMove) - scrollContainer.removeEventListener('touchend', handleTouchEnd) - scrollContainer.removeEventListener('touchcancel', handleTouchEnd) - scrollContainer.scrollTo = originalScrollTo + scrollContainer.removeEventListener("scroll", handleDomScroll); + scrollContainer.removeEventListener("wheel", handleWheel); + scrollContainer.removeEventListener("pointerdown", handlePointerDown); + scrollContainer.removeEventListener("pointerup", handlePointerUp); + scrollContainer.removeEventListener("pointercancel", handlePointerUp); + scrollContainer.removeEventListener("touchstart", handleTouchStart); + scrollContainer.removeEventListener("touchmove", handleTouchMove); + scrollContainer.removeEventListener("touchend", handleTouchEnd); + scrollContainer.removeEventListener("touchcancel", handleTouchEnd); + scrollContainer.scrollTo = originalScrollTo; if (scrollTopDescriptor) { - Reflect.deleteProperty(scrollContainer, 'scrollTop') + Reflect.deleteProperty(scrollContainer, "scrollTop"); } - if (typeof document !== 'undefined') { - document.removeEventListener('selectionchange', handleSelectionChange) + if (typeof document !== "undefined") { + document.removeEventListener("selectionchange", handleSelectionChange); } - } - }, [cancelPendingStickToBottom, handleDomScroll, props.agentId]) + }; + }, [cancelPendingStickToBottom, handleDomScroll, props.agentId]); useEffect(() => { const handle: StreamViewportHandle = { scrollToBottom: () => { - setFollowOutput(true) - cancelPendingStickToBottom() - forceStickToBottom() + setFollowOutput(true); + cancelPendingStickToBottom(); + forceStickToBottom(); }, prepareForViewportChange: () => { if (!followOutputRef.current) { - return + return; } - const scrollContainer = scrollContainerRef.current - logWebStickyBottom('viewport_prepare_for_change', { + const scrollContainer = scrollContainerRef.current; + logWebStickyBottom("viewport_prepare_for_change", { agentId: props.agentId, followOutput: followOutputRef.current, scrollTop: scrollContainer?.scrollTop ?? null, @@ -623,62 +624,68 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool clientHeight: scrollContainer?.clientHeight ?? null, scrollWidth: scrollContainer?.scrollWidth ?? null, scrollHeight: scrollContainer?.scrollHeight ?? null, - }) - scheduleStickToBottom() + }); + scheduleStickToBottom(); }, - } - viewportRef.current = handle + }; + viewportRef.current = handle; return () => { if (viewportRef.current === handle) { - viewportRef.current = null + viewportRef.current = null; } - cancelPendingStickToBottom() - } - }, [cancelPendingStickToBottom, forceStickToBottom, props.agentId, scheduleStickToBottom, viewportRef]) + cancelPendingStickToBottom(); + }; + }, [ + cancelPendingStickToBottom, + forceStickToBottom, + props.agentId, + scheduleStickToBottom, + viewportRef, + ]); const contentContainerStyle = useMemo( (): CSSProperties => ({ - display: 'flex', - flexDirection: 'column', - minHeight: '100%', + display: "flex", + flexDirection: "column", + minHeight: "100%", paddingTop: 16, paddingBottom: 16, paddingLeft: isMobileBreakpoint ? 8 : 16, paddingRight: isMobileBreakpoint ? 8 : 16, - boxSizing: 'border-box', + boxSizing: "border-box", }), - [isMobileBreakpoint] - ) + [isMobileBreakpoint], + ); const scrollContainerStyle = useMemo( (): CSSProperties => ({ flex: 1, minHeight: 0, - overflowX: 'hidden', - overflowY: scrollEnabled ? 'auto' : 'hidden', - overscrollBehaviorY: 'contain', + overflowX: "hidden", + overflowY: scrollEnabled ? "auto" : "hidden", + overscrollBehaviorY: "contain", }), - [scrollEnabled] - ) + [scrollEnabled], + ); const virtualRowsContainerStyle = useMemo( (): CSSProperties => ({ - position: 'relative', - width: '100%', + position: "relative", + width: "100%", height: virtualTotalSize, }), - [virtualTotalSize] - ) + [virtualTotalSize], + ); const renderVirtualRowStyle = useCallback( (start: number): CSSProperties => ({ - position: 'absolute', + position: "absolute", top: 0, left: 0, - display: 'flex', - flexDirection: 'column', - width: '100%', + display: "flex", + flexDirection: "column", + width: "100%", transform: `translateY(${start}px)`, }), - [] - ) + [], + ); const mountedHistoryRows = useMemo( () => segments.historyMounted.map((item, index) => ( @@ -686,47 +693,45 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool {renderHistoryMountedRow(item, index, segments.historyMounted)} )), - [renderHistoryMountedRow, segments.historyMounted] - ) + [renderHistoryMountedRow, segments.historyMounted], + ); const liveHeadRows = useMemo( () => segments.liveHead.map((item, index) => ( - - {renderLiveHeadRow(item, index, segments.liveHead)} - + {renderLiveHeadRow(item, index, segments.liveHead)} )), - [renderLiveHeadRow, segments.liveHead] - ) - const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]) + [renderLiveHeadRow, segments.liveHead], + ); + const liveAuxiliary = useMemo(() => renderLiveAuxiliary(), [renderLiveAuxiliary]); const shouldRenderEmpty = !boundary.hasMountedHistory && !boundary.hasVirtualizedHistory && !boundary.hasLiveHead && - !liveAuxiliary + !liveAuxiliary; return ( <>
{ - scrollContainerRef.current = node + scrollContainerRef.current = node; }} data-testid="agent-chat-scroll" - id={`agent-chat-scroll-${shouldUseVirtualizer ? 'web-dom-virtualized' : 'web-dom-scroll'}`} + id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`} style={scrollContainerStyle} >
{ - contentRef.current = node + contentRef.current = node; }} style={contentContainerStyle} > {shouldUseVirtualizer ? (
{virtualRows.map((virtualRow) => { - const item = segments.historyVirtualized[virtualRow.index] + const item = segments.historyVirtualized[virtualRow.index]; if (!item) { - return null + return null; } return (
- ) + ); })}
) : null} {mountedHistoryRows} {boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? ( -
+
) : null} {liveHeadRows} {liveAuxiliary} @@ -759,17 +764,17 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool metrics={streamScrollbarMetrics} inverted={false} onScrollToOffset={(nextOffset) => { - const scrollContainer = scrollContainerRef.current + const scrollContainer = scrollContainerRef.current; if (!scrollContainer) { - return + return; } - scrollContainer.scrollTo({ top: nextOffset, behavior: 'auto' }) - lastKnownScrollTopRef.current = scrollContainer.scrollTop - updateScrollMetrics() + scrollContainer.scrollTo({ top: nextOffset, behavior: "auto" }); + lastKnownScrollTopRef.current = scrollContainer.scrollTop; + updateScrollMetrics(); }} /> - ) + ); } export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): StreamStrategy { @@ -784,13 +789,13 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St orderTailReverse: false, orderHeadReverse: false, assistantTurnTraversalStep: -1, - edgeSlot: 'footer', + edgeSlot: "footer", flatListInverted: false, overlayScrollbarInverted: false, maintainVisibleContentPosition: undefined, bottomAnchorTransportBehavior: { verificationDelayFrames: 0, - verificationRetryMode: 'rescroll', + verificationRetryMode: "rescroll", }, disableParentScrollOnInlineDetailsExpansion: false, anchorBottomOnContentSizeChange: true, @@ -799,10 +804,10 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St isNearBottom: (inputMetrics) => { const distanceFromBottom = Math.max( 0, - inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight) - ) - return distanceFromBottom <= inputMetrics.threshold + inputMetrics.contentHeight - (inputMetrics.offsetY + inputMetrics.viewportHeight), + ); + return distanceFromBottom <= inputMetrics.threshold; }, getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight), - }) + }); } diff --git a/packages/app/src/components/stream-strategy.ts b/packages/app/src/components/stream-strategy.ts index 507379c01..47a8a05a6 100644 --- a/packages/app/src/components/stream-strategy.ts +++ b/packages/app/src/components/stream-strategy.ts @@ -1,10 +1,7 @@ import type { ComponentType, ReactElement, ReactNode, RefObject } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import type { StreamItem } from "@/types/stream"; -import type { - StreamHistoryBoundary, - StreamRenderSegments, -} from "./agent-stream-render-model"; +import type { StreamHistoryBoundary, StreamRenderSegments } from "./agent-stream-render-model"; import type { BottomAnchorLocalRequest, BottomAnchorRouteRequest, @@ -49,21 +46,9 @@ export type StreamViewportHandle = { }; export type StreamSegmentRenderers = { - renderHistoryVirtualizedRow: ( - item: StreamItem, - index: number, - items: StreamItem[] - ) => ReactNode; - renderHistoryMountedRow: ( - item: StreamItem, - index: number, - items: StreamItem[] - ) => ReactNode; - renderLiveHeadRow: ( - item: StreamItem, - index: number, - items: StreamItem[] - ) => ReactNode; + renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; + renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; + renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode; renderLiveAuxiliary: () => ReactNode; }; @@ -96,18 +81,16 @@ export interface StreamStrategy { getNeighborItem: ( items: StreamItem[], index: number, - relation: NeighborRelation + relation: NeighborRelation, ) => StreamItem | undefined; collectAssistantTurnContent: (items: StreamItem[], startIndex: number) => string; isNearBottom: (input: StreamNearBottomInput) => boolean; getBottomOffset: (metrics: StreamViewportMetrics) => number; getEdgeSlotProps: ( component: ReactElement | ComponentType | null, - gapSize: number + gapSize: number, ) => StreamEdgeSlotProps; - getMaintainVisibleContentPosition: () => - | MaintainVisibleContentPositionConfig - | undefined; + getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined; getBottomAnchorTransportBehavior: () => BottomAnchorTransportBehavior; getFlatListInverted: () => boolean; getOverlayScrollbarInverted: () => boolean; @@ -137,15 +120,12 @@ type StreamStrategyConfig = { const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4; -export function createStreamStrategy( - config: StreamStrategyConfig -): StreamStrategy { +export function createStreamStrategy(config: StreamStrategyConfig): StreamStrategy { return { render: config.render, orderTail: (streamItems) => config.orderTailReverse ? [...streamItems].reverse() : streamItems, - orderHead: (streamHead) => - config.orderHeadReverse ? [...streamHead].reverse() : streamHead, + orderHead: (streamHead) => (config.orderHeadReverse ? [...streamHead].reverse() : streamHead), getNeighborIndex: (index, relation) => relation === "above" ? index + config.assistantTurnTraversalStep @@ -197,15 +177,14 @@ export function createStreamStrategy( getOverlayScrollbarInverted: () => config.overlayScrollbarInverted, shouldDisableParentScrollOnInlineDetailsExpansion: () => config.disableParentScrollOnInlineDetailsExpansion, - shouldAnchorBottomOnContentSizeChange: () => - config.anchorBottomOnContentSizeChange, + shouldAnchorBottomOnContentSizeChange: () => config.anchorBottomOnContentSizeChange, shouldAnimateManualScrollToBottom: () => config.animateManualScrollToBottom, shouldUseVirtualizedList: () => config.useVirtualizedList, }; } export function resolveStreamRenderStrategy( - input: ResolveStreamRenderStrategyInput + input: ResolveStreamRenderStrategyInput, ): StreamStrategy { if (input.platform === "web") { return createWebStreamStrategy({ @@ -226,7 +205,7 @@ export function resolveBottomAnchorTransportBehavior(input: { return { verificationDelayFrames: Math.max( baseBehavior.verificationDelayFrames, - NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES + NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES, ), verificationRetryMode: "recheck", }; @@ -260,11 +239,7 @@ export function getStreamNeighborItem(params: { index: number; relation: NeighborRelation; }): StreamItem | undefined { - return params.strategy.getNeighborItem( - params.items, - params.index, - params.relation - ); + return params.strategy.getNeighborItem(params.items, params.index, params.relation); } export function collectAssistantTurnContentForStreamRenderStrategy(params: { @@ -272,14 +247,11 @@ export function collectAssistantTurnContentForStreamRenderStrategy(params: { items: StreamItem[]; startIndex: number; }): string { - return params.strategy.collectAssistantTurnContent( - params.items, - params.startIndex - ); + return params.strategy.collectAssistantTurnContent(params.items, params.startIndex); } export function isNearBottomForStreamRenderStrategy( - params: StreamNearBottomInput & { strategy: StreamStrategy } + params: StreamNearBottomInput & { strategy: StreamStrategy }, ): boolean { return params.strategy.isNearBottom({ offsetY: params.offsetY, @@ -292,7 +264,7 @@ export function isNearBottomForStreamRenderStrategy( export function getBottomOffsetForStreamRenderStrategy( params: StreamViewportMetrics & { strategy: StreamStrategy; - } + }, ): number { return params.strategy.getBottomOffset({ contentHeight: params.contentHeight, diff --git a/packages/app/src/components/synced-loader.tsx b/packages/app/src/components/synced-loader.tsx index 8e665e5ed..7697f3553 100644 --- a/packages/app/src/components/synced-loader.tsx +++ b/packages/app/src/components/synced-loader.tsx @@ -26,8 +26,7 @@ function ensureSharedStepLoopStarted(): void { } sharedLoopStarted = true; - const elapsedMs = - (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS; + const elapsedMs = (Date.now() - SYNCED_LOADER_EPOCH_MS) % SYNCED_LOADER_DURATION_MS; sharedStepProgress.value = (elapsedMs / SYNCED_LOADER_DURATION_MS) * DOT_COUNT; sharedStepProgress.value = withTiming( DOT_COUNT, @@ -47,19 +46,13 @@ function ensureSharedStepLoopStarted(): void { easing: Easing.linear, }), -1, - false + false, ); - } + }, ); } -export function SyncedLoader({ - size = 10, - color, -}: { - size?: number; - color: string; -}) { +export function SyncedLoader({ size = 10, color }: { size?: number; color: string }) { useEffect(() => { ensureSharedStepLoopStarted(); }, []); @@ -94,9 +87,7 @@ export function SyncedLoader({ {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => { const rowIndex = Math.floor(dotIndex / GRID_COLUMNS); const columnIndex = dotIndex % GRID_COLUMNS; - const sequenceIndex = DOT_SEQUENCE.indexOf( - dotIndex as (typeof DOT_SEQUENCE)[number] - ); + const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]); return ( { runtime.unmount(); @@ -469,10 +466,7 @@ export default function TerminalEmulator({ }, [streamKey]); useEffect(() => { - const maxScrollOffset = Math.max( - 0, - viewportMetrics.contentSize - viewportMetrics.viewportSize - ); + const maxScrollOffset = Math.max(0, viewportMetrics.contentSize - viewportMetrics.viewportSize); const normalizedOffset = clamp(viewportMetrics.offset, 0, maxScrollOffset); if (maxScrollOffset <= 0 || viewportMetrics.viewportSize <= 0) { setIsScrollVisible(false); @@ -504,11 +498,7 @@ export default function TerminalEmulator({ setIsScrollActive(false); scrollActiveTimeoutRef.current = null; }, SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS); - }, [ - viewportMetrics.contentSize, - viewportMetrics.offset, - viewportMetrics.viewportSize, - ]); + }, [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize]); useEffect(() => { return () => { @@ -528,7 +518,7 @@ export default function TerminalEmulator({ contentSize: viewportMetrics.contentSize, offset: viewportMetrics.offset, }), - [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize] + [viewportMetrics.contentSize, viewportMetrics.offset, viewportMetrics.viewportSize], ); useEffect(() => { @@ -569,11 +559,7 @@ export default function TerminalEmulator({ window.removeEventListener("pointerup", stopDragging); window.removeEventListener("pointercancel", stopDragging); }; - }, [ - isDraggingScrollbar, - scrollbarGeometry.maxHandleOffset, - scrollbarGeometry.maxScrollOffset, - ]); + }, [isDraggingScrollbar, scrollbarGeometry.maxHandleOffset, scrollbarGeometry.maxScrollOffset]); const handleVisible = scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered); @@ -590,16 +576,13 @@ export default function TerminalEmulator({ : SCROLLBAR_HANDLE_WIDTH_IDLE; const thumbRegionOffset = Math.max( 0, - scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING + scrollbarGeometry.handleOffset - SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING, ); const thumbRegionHeight = Math.min( viewportMetrics.viewportSize - thumbRegionOffset, - scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2 - ); - const handleInsetTop = Math.max( - 0, - (thumbRegionHeight - scrollbarGeometry.handleSize) / 2 + scrollbarGeometry.handleSize + SCROLLBAR_HANDLE_GRAB_VERTICAL_PADDING * 2, ); + const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2); const handleTravelDurationMs = isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS; @@ -677,7 +660,7 @@ export default function TerminalEmulator({ dragStartOffsetRef.current = clamp( viewportMetrics.offset, 0, - scrollbarGeometry.maxScrollOffset + scrollbarGeometry.maxScrollOffset, ); dragStartClientYRef.current = event.clientY; setIsDraggingScrollbar(true); @@ -703,8 +686,7 @@ export default function TerminalEmulator({ opacity: handleOpacity, transitionProperty: "opacity, width, background-color", transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`, - transitionTimingFunction: - "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", + transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", }} />
diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index ada1657a8..eae652b8e 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -1,21 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; -import { - ActivityIndicator, - Pressable, - ScrollView, - Text, - View, -} from "react-native"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; import { Plus, X } from "lucide-react-native"; import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated"; -import Svg, { - Defs, - LinearGradient as SvgLinearGradient, - Rect, - Stop, -} from "react-native-svg"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import type { ListTerminalsResponse } from "@server/shared/messages"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; @@ -28,9 +17,7 @@ import { } from "@/utils/terminal-keys"; import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { confirmDialog } from "@/utils/confirm-dialog"; -import { - getWorkspaceTerminalSession, -} from "@/terminal/runtime/workspace-terminal-session"; +import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { TerminalStreamController, type TerminalStreamControllerStatus, @@ -109,13 +96,7 @@ function TerminalCloseGradient({ color, gradientId }: { color: string; gradientI - + @@ -138,8 +119,7 @@ export function TerminalPane({ const isScreenFocused = useIsFocused(); const { theme } = useUnistyles(); const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const openAgentList = usePanelStore((state) => state.openAgentList); const openFileExplorer = usePanelStore((state) => state.openFileExplorer); @@ -163,21 +143,18 @@ export function TerminalPane({ scopeKey, maxOutputChars: MAX_OUTPUT_CHARS, }), - [scopeKey] + [scopeKey], ); const outputSession = workspaceTerminalSession.outputSession; const subscribeOutputSession = useCallback( (listener: () => void) => outputSession.subscribe(listener), - [outputSession] - ); - const getOutputSessionState = useCallback( - () => outputSession.getState(), - [outputSession] + [outputSession], ); + const getOutputSessionState = useCallback(() => outputSession.getState(), [outputSession]); const outputState = useSyncExternalStore( subscribeOutputSession, getOutputSessionState, - getOutputSessionState + getOutputSessionState, ); const selectedOutputState = useMemo(() => { if (outputState.selectedTerminalId === selectedTerminalId) { @@ -204,9 +181,7 @@ export function TerminalPane({ const [focusRequestToken, setFocusRequestToken] = useState(0); const [resizeRequestToken, setResizeRequestToken] = useState(0); const [hoveredTerminalId, setHoveredTerminalId] = useState(null); - const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState( - null - ); + const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState(null); const hoverOutTimeoutRef = useRef | null>(null); const selectedTerminalIdRef = useRef(selectedTerminalId); const pendingTerminalInputRef = useRef([]); @@ -217,7 +192,7 @@ export function TerminalPane({ (next: string | null) => { onSelectedTerminalIdChange?.(next); }, - [onSelectedTerminalIdChange] + [onSelectedTerminalIdChange], ); useEffect(() => { @@ -237,7 +212,7 @@ export function TerminalPane({ clearHoverOutTimeout(); setHoveredTerminalId(terminalId); }, - [clearHoverOutTimeout] + [clearHoverOutTimeout], ); const handleTerminalTabHoverOut = useCallback( @@ -245,12 +220,10 @@ export function TerminalPane({ clearHoverOutTimeout(); hoverOutTimeoutRef.current = setTimeout(() => { setHoveredTerminalId((current) => (current === terminalId ? null : current)); - setHoveredCloseTerminalId((current) => - current === terminalId ? null : current - ); + setHoveredCloseTerminalId((current) => (current === terminalId ? null : current)); }, 50); }, - [clearHoverOutTimeout] + [clearHoverOutTimeout], ); const handleTerminalCloseHoverIn = useCallback( @@ -259,7 +232,7 @@ export function TerminalPane({ setHoveredTerminalId(terminalId); setHoveredCloseTerminalId(terminalId); }, - [clearHoverOutTimeout] + [clearHoverOutTimeout], ); const handleTerminalCloseHoverOut = useCallback((terminalId: string) => { @@ -308,7 +281,7 @@ export function TerminalPane({ keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => setTimeout(() => { requestTerminalReflow(); - }, delayMs) + }, delayMs), ); }, [clearKeyboardRefitTimeouts, requestTerminalReflow]); @@ -324,7 +297,7 @@ export function TerminalPane({ } runOnJS(pulseKeyboardRefits)(); }, - [pulseKeyboardRefits] + [pulseKeyboardRefits], ); useFocusEffect( @@ -337,7 +310,7 @@ export function TerminalPane({ const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) => setTimeout(() => { requestTerminalReflow(); - }, delayMs) + }, delayMs), ); return () => { @@ -345,7 +318,7 @@ export function TerminalPane({ clearTimeout(handle); } }; - }, [requestTerminalReflow, selectedTerminalId]) + }, [requestTerminalReflow, selectedTerminalId]), ); const terminalsQuery = useQuery({ @@ -386,12 +359,7 @@ export function TerminalPane({ }, [client, isConnected]); useEffect(() => { - if ( - !manageTerminalDirectorySubscription || - !client || - !isConnected || - !cwd.startsWith("/") - ) { + if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) { return; } @@ -494,21 +462,18 @@ export function TerminalPane({ streamControllerRef.current?.pruneResumeOffsets({ terminalIds }); }, [outputSession, terminals]); - const handleStreamControllerStatus = useCallback( - (status: TerminalStreamControllerStatus) => { - setIsAttaching(status.isAttaching); - setStreamError(status.error); - if (status.terminalId && typeof status.streamId === "number") { - setActiveStream({ - terminalId: status.terminalId, - streamId: status.streamId, - }); - return; - } - setActiveStream(null); - }, - [] - ); + const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => { + setIsAttaching(status.isAttaching); + setStreamError(status.error); + if (status.terminalId && typeof status.streamId === "number") { + setActiveStream({ + terminalId: status.terminalId, + streamId: status.streamId, + }); + return; + } + setActiveStream(null); + }, []); useEffect(() => { streamControllerRef.current?.dispose(); @@ -566,16 +531,14 @@ export function TerminalPane({ }, [isScreenFocused, outputSession, selectedTerminalId]); const activeStreamId = - activeStream && activeStream.terminalId === selectedTerminalId - ? activeStream.streamId - : null; + activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null; const getCurrentActiveStreamId = useCallback(() => { return streamControllerRef.current?.getActiveStreamId() ?? null; }, []); const selectedTerminal = useMemo( () => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null, - [terminals, selectedTerminalId] + [terminals, selectedTerminalId], ); const handleCreateTerminal = useCallback(() => { createTerminalMutation.mutate(); @@ -618,7 +581,7 @@ export function TerminalPane({ }); return true; }, - [client] + [client], ); const flushPendingTerminalInput = useCallback(() => { @@ -650,10 +613,7 @@ export function TerminalPane({ const handleCloseTerminal = useCallback( async (terminalId: string) => { - if ( - killTerminalMutation.isPending && - killTerminalMutation.variables === terminalId - ) { + if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) { return; } @@ -671,7 +631,7 @@ export function TerminalPane({ killTerminalMutation.mutate(terminalId); }, - [killTerminalMutation] + [killTerminalMutation], ); const clearPendingModifiers = useCallback(() => { @@ -679,15 +639,13 @@ export function TerminalPane({ }, []); const sendTerminalKey = useCallback( - ( - input: { - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta?: boolean; - } - ): boolean => { + (input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta?: boolean; + }): boolean => { if (!client || !selectedTerminalIdRef.current) { enqueuePendingTerminalInput({ type: "key", @@ -718,12 +676,7 @@ export function TerminalPane({ } return true; }, - [ - client, - dispatchTerminalInputEntry, - enqueuePendingTerminalInput, - getCurrentActiveStreamId, - ] + [client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, getCurrentActiveStreamId], ); const handleTerminalData = useCallback( @@ -782,7 +735,7 @@ export function TerminalPane({ modifiers.shift, sendTerminalKey, enqueuePendingTerminalInput, - ] + ], ); const handleTerminalResize = useCallback( @@ -794,11 +747,7 @@ export function TerminalPane({ const normalizedRows = Math.floor(rows); const normalizedCols = Math.floor(cols); const previous = lastReportedSizeRef.current; - if ( - previous && - previous.rows === normalizedRows && - previous.cols === normalizedCols - ) { + if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) { return; } lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols }; @@ -808,36 +757,33 @@ export function TerminalPane({ cols: normalizedCols, }); }, - [client, selectedTerminalId] + [client, selectedTerminalId], ); const handleTerminalKey = useCallback( - async (input: { - key: string; - ctrl: boolean; - shift: boolean; - alt: boolean; - meta: boolean; - }) => { + async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => { sendTerminalKey(input); }, - [sendTerminalKey] + [sendTerminalKey], ); const handlePendingModifiersConsumed = useCallback(() => { clearPendingModifiers(); }, [clearPendingModifiers]); - const handleOutputChunkConsumed = useCallback((sequence: number) => { - outputSession.consume({ sequence }); - }, [outputSession]); + const handleOutputChunkConsumed = useCallback( + (sequence: number) => { + outputSession.consume({ sequence }); + }, + [outputSession], + ); const toggleModifier = useCallback( (modifier: keyof ModifierState) => { setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); requestTerminalFocus(); }, - [requestTerminalFocus] + [requestTerminalFocus], ); const sendVirtualKey = useCallback( @@ -859,7 +805,7 @@ export function TerminalPane({ modifiers.shift, requestTerminalFocus, sendTerminalKey, - ] + ], ); if (!client || !isConnected) { @@ -870,17 +816,12 @@ export function TerminalPane({ ); } - const queryError = - terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null; + const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null; const isCreating = createTerminalMutation.isPending; const createError = - createTerminalMutation.error instanceof Error - ? createTerminalMutation.error.message - : null; + createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null; const closeError = - killTerminalMutation.error instanceof Error - ? killTerminalMutation.error.message - : null; + killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null; const combinedError = streamError ?? closeError ?? createError ?? queryError; return ( @@ -898,13 +839,11 @@ export function TerminalPane({ const isTabHovered = hoveredTerminalId === terminal.id; const isCloseHovered = hoveredCloseTerminalId === terminal.id; const isClosingTerminal = - killTerminalMutation.isPending && - killTerminalMutation.variables === terminal.id; - const shouldShowCloseButton = - isTabHovered || isCloseHovered || isClosingTerminal; + killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id; + const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal; const gradientId = `terminal-close-gradient-${terminal.id.replace( /[^a-zA-Z0-9_-]/g, - "-" + "-", )}`; return ( {({ hovered = false, pressed = false }) => { const iconColor = - hovered || pressed - ? theme.colors.foreground - : theme.colors.foregroundMuted; + hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted; return ( <> + ) : null} @@ -1077,7 +1010,12 @@ export function TerminalPane({ (hovered || pressed) && styles.keyButtonHovered, ]} > - + {MODIFIER_LABELS[modifier]} diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 5be6f8c1a..5ffe0c6c3 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -1,20 +1,6 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { - Animated, - Easing, - Platform, - Text, - ToastAndroid, - View, -} from "react-native"; +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 { AlertTriangle, CheckCircle2 } from "lucide-react-native"; @@ -63,46 +49,33 @@ export function useToastHost(): { const [toast, setToast] = useState(null); const idRef = useRef(0); - const show = useCallback( - (content: ReactNode, options?: ToastShowOptions) => { - const nativeMessage = - typeof content === "string" - ? content.trim() - : null; - if (!content || nativeMessage === "") { - return; - } + const show = useCallback((content: ReactNode, options?: ToastShowOptions) => { + const nativeMessage = typeof content === "string" ? content.trim() : null; + if (!content || nativeMessage === "") { + return; + } - const variant = options?.variant ?? "default"; - const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS; - const nativeAndroid = options?.nativeAndroid ?? false; + const variant = options?.variant ?? "default"; + const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS; + const nativeAndroid = options?.nativeAndroid ?? false; - if (Platform.OS === "android" && nativeAndroid && nativeMessage) { - const duration = - durationMs <= 2500 - ? ToastAndroid.SHORT - : ToastAndroid.LONG; - ToastAndroid.showWithGravity( - nativeMessage, - duration, - ToastAndroid.TOP - ); - return; - } + if (Platform.OS === "android" && nativeAndroid && nativeMessage) { + const duration = durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; + ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP); + return; + } - idRef.current += 1; - setToast({ - id: idRef.current, - content, - nativeMessage, - icon: options?.icon, - variant, - durationMs, - testID: options?.testID, - }); - }, - [] - ); + idRef.current += 1; + setToast({ + id: idRef.current, + content, + nativeMessage, + icon: options?.icon, + variant, + durationMs, + testID: options?.testID, + }); + }, []); const api = useMemo( () => ({ @@ -112,10 +85,9 @@ export function useToastHost(): { variant: "success", icon: , }), - error: (message: string) => - show(message, { variant: "error", durationMs: 3200 }), + error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), }), - [show] + [show], ); const dismiss = useCallback(() => { @@ -209,8 +181,7 @@ export function ToastViewport({ return null; } - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT; const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0; const topOffset = @@ -219,13 +190,12 @@ export function ToastViewport({ : theme.spacing[3]; const icon = - toast.icon ?? ( - toast.variant === "success" ? ( - - ) : toast.variant === "error" ? ( - - ) : null - ); + toast.icon ?? + (toast.variant === "success" ? ( + + ) : toast.variant === "error" ? ( + + ) : null); const content = ( @@ -247,10 +217,7 @@ export function ToastViewport({ {typeof toast.content === "string" ? ( {toast.content} @@ -264,11 +231,7 @@ export function ToastViewport({ ); - if ( - placement === "app-shell" && - Platform.OS === "web" && - typeof document !== "undefined" - ) { + if (placement === "app-shell" && Platform.OS === "web" && typeof document !== "undefined") { return createPortal(content, getOverlayRoot()); } diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index a8169ca15..2e966327c 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -4,10 +4,7 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { ToolCallDetail } from "@server/server/agent/agent-sdk-types"; -import { - buildLineDiff, - parseUnifiedDiff, -} from "@/utils/tool-call-parsers"; +import { buildLineDiff, parseUnifiedDiff } from "@/utils/tool-call-parsers"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; @@ -60,10 +57,7 @@ export function ToolCallDetailsContent({ const commandOutput = (detail.output ?? "").replace(/^\n+/, ""); const hasOutput = commandOutput.length > 0; sections.push( - + - + , ); } else if (detail?.type === "worktree_setup") { const setupLog = detail.log.replace(/^\n+/, ""); const hasLog = setupLog.length > 0; sections.push( - + - + , ); } else if (detail?.type === "sub_agent") { const activityLog = detail.log.replace(/^\n+/, ""); @@ -136,12 +127,9 @@ export function ToolCallDetailsContent({ const fallbackHeader = detail.subAgentType && detail.description ? `${detail.subAgentType}: ${detail.description}` - : detail.subAgentType ?? detail.description ?? "Sub-agent activity"; + : (detail.subAgentType ?? detail.description ?? "Sub-agent activity"); sections.push( - + - + , ); } else if (detail?.type === "edit") { sections.push( - + {diffLines ? ( ) : null} - + , ); } else if (detail?.type === "write") { sections.push( - + {detail.content ? ( - - {detail.content} + + + {detail.content} + ) : null} - + , ); } else if (detail?.type === "read") { if (detail.content) { sections.push( - + - - {detail.content} + + + {detail.content} + - + , ); } } else if (detail?.type === "search") { @@ -247,8 +222,10 @@ export function ToolCallDetailsContent({ if (detail.query) { searchSections.push( - {detail.query} - + + {detail.query} + + , ); } if (detail.content) { @@ -264,17 +241,21 @@ export function ToolCallDetailsContent({ showsVerticalScrollIndicator > - {detail.content} + + {detail.content} + - + , ); } if (detail.filePaths && detail.filePaths.length > 0) { searchSections.push( - {detail.filePaths.join("\n")} - + + {detail.filePaths.join("\n")} + + , ); } if (detail.webResults && detail.webResults.length > 0) { @@ -283,23 +264,22 @@ export function ToolCallDetailsContent({ {detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")} - + , ); } if (detail.annotations && detail.annotations.length > 0) { searchSections.push( - {detail.annotations.join("\n\n")} - + + {detail.annotations.join("\n\n")} + + , ); } sections.push(...searchSections); } else if (detail?.type === "fetch") { sections.push( - + - + , ); } else if (detail?.type === "plain_text") { if (detail.text) { sections.push( - {detail.text} - + + {detail.text} + + , ); } } else if (detail?.type === "unknown") { const plainInputText = - typeof detail.input === "string" && detail.output === null - ? detail.input - : null; + typeof detail.input === "string" && detail.output === null ? detail.input : null; if (plainInputText !== null) { sections.push( - {plainInputText} - + + {plainInputText} + + , ); } else { const sectionsFromTopLevel = [ { title: "Input", value: detail.input }, { title: "Output", value: detail.output }, - ].filter((entry) => hasMeaningfulToolCallDetail({ - type: "unknown", - input: entry.value ?? null, - output: null, - })); + ].filter((entry) => + hasMeaningfulToolCallDetail({ + type: "unknown", + input: entry.value ?? null, + output: null, + }), + ); for (const section of sectionsFromTopLevel) { let value = ""; @@ -364,7 +348,7 @@ export function ToolCallDetailsContent({ sections.push( {section.title} - + , ); sections.push( @@ -375,9 +359,11 @@ export function ToolCallDetailsContent({ contentContainerStyle={styles.jsonContent} showsHorizontalScrollIndicator={true} > - {value} + + {value} + - + , ); } } @@ -399,28 +385,21 @@ export function ToolCallDetailsContent({ {errorText} - + , ); } if (sections.length === 0) { if (showLoadingSkeleton) { return ( - + ); } - return ( - No additional details available - ); + return No additional details available; } return ( @@ -449,20 +428,20 @@ const styles = StyleSheet.create((theme) => { gap: theme.spacing[2], padding: 0, }, - groupHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - groupHeaderText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, - }, + groupHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + groupHeaderText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + }, section: { gap: theme.spacing[2], }, @@ -470,28 +449,28 @@ const styles = StyleSheet.create((theme) => { flex: 1, minHeight: 0, }, - plainTextSection: { - gap: theme.spacing[2], - padding: theme.spacing[3], - }, - plainText: { - fontFamily: Fonts.sans, - fontSize: theme.fontSize.base, - color: theme.colors.foreground, - lineHeight: 22, - overflowWrap: "anywhere", - }, - sectionTitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.semibold, - textTransform: "uppercase", - letterSpacing: 0.5, - }, - rangeText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, + plainTextSection: { + gap: theme.spacing[2], + padding: theme.spacing[3], + }, + plainText: { + fontFamily: Fonts.sans, + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + lineHeight: 22, + overflowWrap: "anywhere", + }, + sectionTitle: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + rangeText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, diffContainer: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, @@ -505,28 +484,28 @@ const styles = StyleSheet.create((theme) => { overflow: "hidden", backgroundColor: theme.colors.surface1, }, - codeVerticalScroll: {}, - codeVerticalContent: { - flexGrow: 1, - paddingBottom: insets.extraBottom, - }, - codeHorizontalContent: { - paddingRight: insets.extraRight, - }, - codeLine: { - minWidth: "100%", - paddingHorizontal: insets.padding, - paddingVertical: insets.padding, - }, - scrollArea: { - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.base, - backgroundColor: theme.colors.surface2, - }, - scrollContent: { - padding: insets.padding, - }, + codeVerticalScroll: {}, + codeVerticalContent: { + flexGrow: 1, + paddingBottom: insets.extraBottom, + }, + codeHorizontalContent: { + paddingRight: insets.extraRight, + }, + codeLine: { + minWidth: "100%", + paddingHorizontal: insets.padding, + paddingVertical: insets.padding, + }, + scrollArea: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.base, + backgroundColor: theme.colors.surface2, + }, + scrollContent: { + padding: insets.padding, + }, scrollText: { fontFamily: Fonts.mono, fontSize: theme.fontSize.xs, @@ -546,43 +525,43 @@ const styles = StyleSheet.create((theme) => { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, borderRadius: theme.borderRadius.base, - backgroundColor: theme.colors.surface2, - }, - jsonScrollError: { - borderColor: theme.colors.destructive, - }, - jsonContent: { - padding: insets.padding, - }, - errorText: { - color: theme.colors.destructive, - }, - emptyStateText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontStyle: "italic", - }, - loadingContainer: { - gap: theme.spacing[2], - padding: theme.spacing[3], - }, - loadingLineWide: { - height: 12, - width: "100%", - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface3, - }, - loadingLineMedium: { - height: 12, - width: "72%", - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface3, - }, - loadingLineShort: { - height: 12, - width: "48%", - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface3, - }, + backgroundColor: theme.colors.surface2, + }, + jsonScrollError: { + borderColor: theme.colors.destructive, + }, + jsonContent: { + padding: insets.padding, + }, + errorText: { + color: theme.colors.destructive, + }, + emptyStateText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontStyle: "italic", + }, + loadingContainer: { + gap: theme.spacing[2], + padding: theme.spacing[3], + }, + loadingLineWide: { + height: 12, + width: "100%", + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + }, + loadingLineMedium: { + height: 12, + width: "72%", + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + }, + loadingLineShort: { + height: 12, + width: "48%", + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + }, }; }); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index bfc98de72..03568b35e 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -1,11 +1,4 @@ -import React, { - createContext, - useContext, - useCallback, - useMemo, - useRef, - ReactNode, -} from "react"; +import React, { createContext, useContext, useCallback, useMemo, useRef, ReactNode } from "react"; import { View, Text, Pressable } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import Animated from "react-native-reanimated"; @@ -54,7 +47,7 @@ function CustomSheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); const containerStyle = useMemo( () => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }], - [style, theme.colors.surface2] + [style, theme.colors.surface2], ); return ; } @@ -88,19 +81,14 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) const renderBackdrop = useCallback( (props: React.ComponentProps) => ( - + ), - [] + [], ); const contextValue = useMemo( () => ({ openToolCall, closeToolCall }), - [openToolCall, closeToolCall] + [openToolCall, closeToolCall], ); return ( @@ -152,10 +140,7 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) { {/* Content */} - + { it("keeps first logical option closest to the input by default", () => { - expect(orderAutocompleteOptions(OPTIONS)).toEqual([ - "gamma", - "beta", - "alpha", - ]); + expect(orderAutocompleteOptions(OPTIONS)).toEqual(["gamma", "beta", "alpha"]); }); it("keeps normal top-down order when below-input is selected", () => { - expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual([ - "alpha", - "beta", - "gamma", - ]); + expect(orderAutocompleteOptions(OPTIONS, "below-input")).toEqual(["alpha", "beta", "gamma"]); }); }); @@ -45,7 +37,7 @@ describe("getAutocompleteScrollOffset", () => { viewportHeight: 80, itemTop: 90, itemHeight: 20, - }) + }), ).toBe(90); }); @@ -56,7 +48,7 @@ describe("getAutocompleteScrollOffset", () => { viewportHeight: 100, itemTop: 150, itemHeight: 24, - }) + }), ).toBe(74); }); }); diff --git a/packages/app/src/components/ui/autocomplete-utils.ts b/packages/app/src/components/ui/autocomplete-utils.ts index cc22ecda1..92f5c6e95 100644 --- a/packages/app/src/components/ui/autocomplete-utils.ts +++ b/packages/app/src/components/ui/autocomplete-utils.ts @@ -4,7 +4,7 @@ export type AutocompleteOptionsPosition = "above-input" | "below-input"; export function orderAutocompleteOptions( options: readonly T[], - position: AutocompleteOptionsPosition = "above-input" + position: AutocompleteOptionsPosition = "above-input", ): T[] { if (position === "below-input") { return [...options]; @@ -14,7 +14,7 @@ export function orderAutocompleteOptions( export function getAutocompleteFallbackIndex( itemCount: number, - position: AutocompleteOptionsPosition = "above-input" + position: AutocompleteOptionsPosition = "above-input", ): number { if (itemCount <= 0) { return -1; diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index e501cabbe..a0e5f11be 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -1,37 +1,37 @@ -import { useCallback, useEffect, useRef } from 'react' -import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native' -import { StyleSheet, useUnistyles } from 'react-native-unistyles' -import { File, Folder } from 'lucide-react-native' -import { Theme } from '@/styles/theme' -import { getAutocompleteScrollOffset } from './autocomplete-utils' +import { useCallback, useEffect, useRef } from "react"; +import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { File, Folder } from "lucide-react-native"; +import { Theme } from "@/styles/theme"; +import { getAutocompleteScrollOffset } from "./autocomplete-utils"; export interface AutocompleteOption { - id: string - label: string - detail?: string - description?: string - kind?: 'command' | 'file' | 'directory' + id: string; + label: string; + detail?: string; + description?: string; + kind?: "command" | "file" | "directory"; } interface AutocompleteProps { - options: readonly AutocompleteOption[] - selectedIndex: number - onSelect: (option: AutocompleteOption) => void - isLoading?: boolean - errorMessage?: string - loadingText?: string - emptyText?: string - maxHeight?: number + options: readonly AutocompleteOption[]; + selectedIndex: number; + onSelect: (option: AutocompleteOption) => void; + isLoading?: boolean; + errorMessage?: string; + loadingText?: string; + emptyText?: string; + maxHeight?: number; } -const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g +const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g; function removeBoltGlyphs(value?: string): string | undefined { if (!value) { - return value + return value; } - const cleaned = value.replace(BOLT_GLYPH_PATTERN, '').trim() - return cleaned.length > 0 ? cleaned : undefined + const cleaned = value.replace(BOLT_GLYPH_PATTERN, "").trim(); + return cleaned.length > 0 ? cleaned : undefined; } export function Autocomplete({ @@ -40,24 +40,24 @@ export function Autocomplete({ onSelect, isLoading = false, errorMessage, - loadingText = 'Loading...', - emptyText = 'No results found', + loadingText = "Loading...", + emptyText = "No results found", maxHeight = 220, }: AutocompleteProps) { - const { theme } = useUnistyles() - const scrollRef = useRef(null) - const rowLayoutsRef = useRef>(new Map()) - const viewportHeightRef = useRef(0) - const scrollOffsetRef = useRef(0) + const { theme } = useUnistyles(); + const scrollRef = useRef(null); + const rowLayoutsRef = useRef>(new Map()); + const viewportHeightRef = useRef(0); + const scrollOffsetRef = useRef(0); const ensureActiveItemVisible = useCallback(() => { if (selectedIndex < 0) { - return + return; } - const layout = rowLayoutsRef.current.get(selectedIndex) + const layout = rowLayoutsRef.current.get(selectedIndex); if (!layout) { - return + return; } const nextOffset = getAutocompleteScrollOffset({ @@ -65,60 +65,60 @@ export function Autocomplete({ viewportHeight: viewportHeightRef.current, itemTop: layout.top, itemHeight: layout.height, - }) + }); if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) { - return + return; } - scrollOffsetRef.current = nextOffset - scrollRef.current?.scrollTo({ y: nextOffset, animated: false }) - }, [selectedIndex]) + scrollOffsetRef.current = nextOffset; + scrollRef.current?.scrollTo({ y: nextOffset, animated: false }); + }, [selectedIndex]); const pinToBottom = useCallback(() => { - scrollRef.current?.scrollToEnd({ animated: false }) + scrollRef.current?.scrollToEnd({ animated: false }); requestAnimationFrame(() => { - scrollRef.current?.scrollToEnd({ animated: false }) - }) - }, []) + scrollRef.current?.scrollToEnd({ animated: false }); + }); + }, []); useEffect(() => { - rowLayoutsRef.current.clear() - scrollOffsetRef.current = 0 - }, [options]) + rowLayoutsRef.current.clear(); + scrollOffsetRef.current = 0; + }, [options]); useEffect(() => { if (options.length === 0) { - return + return; } - pinToBottom() - }, [options, pinToBottom]) + pinToBottom(); + }, [options, pinToBottom]); useEffect(() => { - const raf = requestAnimationFrame(ensureActiveItemVisible) + const raf = requestAnimationFrame(ensureActiveItemVisible); return () => { - cancelAnimationFrame(raf) - } - }, [ensureActiveItemVisible, options.length]) + cancelAnimationFrame(raf); + }; + }, [ensureActiveItemVisible, options.length]); const handleScrollViewLayout = useCallback( (event: LayoutChangeEvent) => { - viewportHeightRef.current = event.nativeEvent.layout.height - ensureActiveItemVisible() + viewportHeightRef.current = event.nativeEvent.layout.height; + ensureActiveItemVisible(); }, - [ensureActiveItemVisible] - ) + [ensureActiveItemVisible], + ); const handleRowLayout = useCallback( (index: number, event: LayoutChangeEvent) => { rowLayoutsRef.current.set(index, { top: event.nativeEvent.layout.y, height: event.nativeEvent.layout.height, - }) - ensureActiveItemVisible() + }); + ensureActiveItemVisible(); }, - [ensureActiveItemVisible] - ) + [ensureActiveItemVisible], + ); if (isLoading) { return ( @@ -127,7 +127,7 @@ export function Autocomplete({ {loadingText} - ) + ); } if (errorMessage) { @@ -137,7 +137,7 @@ export function Autocomplete({ Error: {errorMessage} - ) + ); } if (options.length === 0) { @@ -147,7 +147,7 @@ export function Autocomplete({ {emptyText} - ) + ); } return ( @@ -157,7 +157,7 @@ export function Autocomplete({ onLayout={handleScrollViewLayout} onContentSizeChange={pinToBottom} onScroll={(event) => { - scrollOffsetRef.current = event.nativeEvent.contentOffset.y + scrollOffsetRef.current = event.nativeEvent.contentOffset.y; }} scrollEventThrottle={16} style={styles.scrollView} @@ -165,10 +165,10 @@ export function Autocomplete({ keyboardShouldPersistTaps="always" > {options.map((option, index) => { - const isSelected = index === selectedIndex - const optionLabel = removeBoltGlyphs(option.label) ?? option.label - const optionDetail = removeBoltGlyphs(option.detail) - const optionDescription = removeBoltGlyphs(option.description) + const isSelected = index === selectedIndex; + const optionLabel = removeBoltGlyphs(option.label) ?? option.label; + const optionDetail = removeBoltGlyphs(option.detail); + const optionDescription = removeBoltGlyphs(option.description); return ( - {option.kind === 'directory' || option.kind === 'file' ? ( + {option.kind === "directory" || option.kind === "file" ? ( - {option.kind === 'directory' ? ( + {option.kind === "directory" ? ( ) : ( @@ -200,11 +200,11 @@ export function Autocomplete({ ) : null} - ) + ); })} - ) + ); } const styles = StyleSheet.create(((theme: Theme) => ({ @@ -213,7 +213,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({ borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, - overflow: 'hidden', + overflow: "hidden", }, scrollView: { flexGrow: 0, @@ -223,16 +223,16 @@ const styles = StyleSheet.create(((theme: Theme) => ({ paddingVertical: theme.spacing[1], }, item: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", minHeight: 36, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], }, itemLeading: { width: 18, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", marginRight: theme.spacing[1], }, itemActive: { @@ -243,8 +243,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({ minWidth: 0, }, itemHeader: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], }, itemLabel: { @@ -269,4 +269,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, -})) as any) as Record +})) as any) as Record; diff --git a/packages/app/src/components/ui/combobox-keyboard.test.ts b/packages/app/src/components/ui/combobox-keyboard.test.ts index 62acc5180..912f38f9a 100644 --- a/packages/app/src/components/ui/combobox-keyboard.test.ts +++ b/packages/app/src/components/ui/combobox-keyboard.test.ts @@ -4,30 +4,19 @@ import { getNextActiveIndex } from "./combobox-keyboard"; describe("getNextActiveIndex", () => { it("returns -1 when itemCount is 0", () => { - expect( - getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" }) - ).toBe(-1); + expect(getNextActiveIndex({ currentIndex: 0, itemCount: 0, key: "ArrowDown" })).toBe(-1); }); it("starts at 0 on ArrowDown when no active item", () => { - expect( - getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" }) - ).toBe(0); + expect(getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowDown" })).toBe(0); }); it("starts at last on ArrowUp when no active item", () => { - expect( - getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" }) - ).toBe(2); + expect(getNextActiveIndex({ currentIndex: -1, itemCount: 3, key: "ArrowUp" })).toBe(2); }); it("wraps around on ArrowDown and ArrowUp", () => { - expect( - getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" }) - ).toBe(0); - expect( - getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" }) - ).toBe(2); + expect(getNextActiveIndex({ currentIndex: 2, itemCount: 3, key: "ArrowDown" })).toBe(0); + expect(getNextActiveIndex({ currentIndex: 0, itemCount: 3, key: "ArrowUp" })).toBe(2); }); }); - diff --git a/packages/app/src/components/ui/combobox-keyboard.ts b/packages/app/src/components/ui/combobox-keyboard.ts index 6debe2c33..99256303c 100644 --- a/packages/app/src/components/ui/combobox-keyboard.ts +++ b/packages/app/src/components/ui/combobox-keyboard.ts @@ -16,4 +16,3 @@ export function getNextActiveIndex(args: { ? (normalizedCurrent + 1) % itemCount : (normalizedCurrent - 1 + itemCount) % itemCount; } - diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index 4f6c751b5..18a205427 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -1,95 +1,97 @@ -export type ComboboxOptionKind = 'directory' | 'file' +export type ComboboxOptionKind = "directory" | "file"; export interface ComboboxOptionModel { - id: string - label: string - description?: string - kind?: ComboboxOptionKind + id: string; + label: string; + description?: string; + kind?: ComboboxOptionKind; } export interface BuildVisibleComboboxOptionsInput { - options: ComboboxOptionModel[] - searchQuery: string - searchable: boolean - allowCustomValue: boolean - customValuePrefix: string - customValueDescription?: string - customValueKind?: ComboboxOptionKind + options: ComboboxOptionModel[]; + searchQuery: string; + searchable: boolean; + allowCustomValue: boolean; + customValuePrefix: string; + customValueDescription?: string; + customValueKind?: ComboboxOptionKind; } export function shouldShowCustomComboboxOption(input: { - options: ComboboxOptionModel[] - searchQuery: string - searchable: boolean - allowCustomValue: boolean + options: ComboboxOptionModel[]; + searchQuery: string; + searchable: boolean; + allowCustomValue: boolean; }): boolean { - const sanitizedSearchValue = input.searchQuery.trim() + const sanitizedSearchValue = input.searchQuery.trim(); if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) { - return false + return false; } return !input.options.some( (opt) => opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || - opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() - ) + opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase(), + ); } export function buildVisibleComboboxOptions( - input: BuildVisibleComboboxOptionsInput + input: BuildVisibleComboboxOptionsInput, ): ComboboxOptionModel[] { - const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : '' + const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; const filteredOptions = normalizedSearch ? input.options.filter( (opt) => opt.label.toLowerCase().includes(normalizedSearch) || opt.id.toLowerCase().includes(normalizedSearch) || - opt.description?.toLowerCase().includes(normalizedSearch) + opt.description?.toLowerCase().includes(normalizedSearch), ) - : input.options + : input.options; - const sanitizedSearchValue = input.searchQuery.trim() + const sanitizedSearchValue = input.searchQuery.trim(); const showCustomOption = shouldShowCustomComboboxOption({ options: input.options, searchQuery: input.searchQuery, searchable: input.searchable, allowCustomValue: input.allowCustomValue, - }) + }); - const visibleOptions: ComboboxOptionModel[] = [] + const visibleOptions: ComboboxOptionModel[] = []; if (showCustomOption) { - const trimmedPrefix = input.customValuePrefix.trim() + const trimmedPrefix = input.customValuePrefix.trim(); const customLabel = - trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue + trimmedPrefix.length > 0 + ? `${trimmedPrefix} "${sanitizedSearchValue}"` + : sanitizedSearchValue; visibleOptions.push({ id: sanitizedSearchValue, label: customLabel, description: input.customValueDescription, kind: input.customValueKind, - }) + }); } - visibleOptions.push(...filteredOptions) - return visibleOptions + visibleOptions.push(...filteredOptions); + return visibleOptions; } export function orderVisibleComboboxOptions( visibleOptions: ComboboxOptionModel[], - optionsPosition: 'below-search' | 'above-search' + optionsPosition: "below-search" | "above-search", ): ComboboxOptionModel[] { - if (optionsPosition !== 'above-search') { - return visibleOptions + if (optionsPosition !== "above-search") { + return visibleOptions; } - return [...visibleOptions].reverse() + return [...visibleOptions].reverse(); } export function getComboboxFallbackIndex( itemCount: number, - optionsPosition: 'below-search' | 'above-search' + optionsPosition: "below-search" | "above-search", ): number { if (itemCount <= 0) { - return -1 + return -1; } - return optionsPosition === 'above-search' ? itemCount - 1 : 0 + return optionsPosition === "above-search" ? itemCount - 1 : 0; } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 90303159b..6212a2e3c 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ReactElement, ReactNode } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import type { ReactElement, ReactNode } from "react"; import { View, Text, @@ -10,95 +10,95 @@ import { Platform, StatusBar, useWindowDimensions, -} from 'react-native' -import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' +} from "react-native"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop, BottomSheetTextInput, BottomSheetBackgroundProps, -} from '@gorhom/bottom-sheet' -import Animated, { FadeIn, FadeOut } from 'react-native-reanimated' -import { Check, File, Folder, Search } from 'lucide-react-native' +} from "@gorhom/bottom-sheet"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { Check, File, Folder, Search } from "lucide-react-native"; import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating, -} from '@floating-ui/react-native' -import { getNextActiveIndex } from './combobox-keyboard' +} from "@floating-ui/react-native"; +import { getNextActiveIndex } from "./combobox-keyboard"; import { buildVisibleComboboxOptions, getComboboxFallbackIndex, orderVisibleComboboxOptions, shouldShowCustomComboboxOption, -} from './combobox-options' -import type { ComboboxOptionModel } from './combobox-options' +} from "./combobox-options"; +import type { ComboboxOptionModel } from "./combobox-options"; -const IS_WEB = Platform.OS === 'web' +const IS_WEB = Platform.OS === "web"; -export type ComboboxOption = ComboboxOptionModel +export type ComboboxOption = ComboboxOptionModel; export interface ComboboxProps { - options: ComboboxOption[] - value: string - onSelect: (id: string) => void + options: ComboboxOption[]; + value: string; + onSelect: (id: string) => void; renderOption?: (input: { - option: ComboboxOption - selected: boolean - active: boolean - onPress: () => void - }) => ReactElement - onSearchQueryChange?: (query: string) => void - searchable?: boolean - placeholder?: string - searchPlaceholder?: string - emptyText?: string - allowCustomValue?: boolean - customValuePrefix?: string - customValueDescription?: string - customValueKind?: 'directory' | 'file' - optionsPosition?: 'below-search' | 'above-search' - title?: string - open?: boolean - onOpenChange?: (open: boolean) => void - enableDismissOnClose?: boolean - desktopPlacement?: 'top-start' | 'bottom-start' + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ReactElement; + onSearchQueryChange?: (query: string) => void; + searchable?: boolean; + placeholder?: string; + searchPlaceholder?: string; + emptyText?: string; + allowCustomValue?: boolean; + customValuePrefix?: string; + customValueDescription?: string; + customValueKind?: "directory" | "file"; + optionsPosition?: "below-search" | "above-search"; + title?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + enableDismissOnClose?: boolean; + desktopPlacement?: "top-start" | "bottom-start"; /** * Prevents an initial frame at 0,0 by hiding desktop content until floating * coordinates resolve. This intentionally disables fade enter/exit animation * for that combobox instance to avoid animation overriding hidden opacity. */ - desktopPreventInitialFlash?: boolean - anchorRef: React.RefObject - children?: ReactNode + desktopPreventInitialFlash?: boolean; + anchorRef: React.RefObject; + children?: ReactNode; } function toNumericStyleValue(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) { - return value + if (typeof value === "number" && Number.isFinite(value)) { + return value; } - if (typeof value === 'string') { - const parsed = Number.parseFloat(value) + if (typeof value === "string") { + const parsed = Number.parseFloat(value); if (Number.isFinite(parsed)) { - return parsed + return parsed; } } - return null + return null; } function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { - return + return ; } export interface SearchInputProps { - placeholder: string - value: string - onChangeText: (text: string) => void - onSubmitEditing?: () => void - autoFocus?: boolean + placeholder: string; + value: string; + onChangeText: (text: string) => void; + onSubmitEditing?: () => void; + autoFocus?: boolean; } export function SearchInput({ @@ -108,18 +108,18 @@ export function SearchInput({ onSubmitEditing, autoFocus = false, }: SearchInputProps): ReactElement { - const { theme } = useUnistyles() - const inputRef = useRef(null) - const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput + const { theme } = useUnistyles(); + const inputRef = useRef(null); + const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; useEffect(() => { if (autoFocus && IS_WEB && inputRef.current) { const timer = setTimeout(() => { - inputRef.current?.focus() - }, 50) - return () => clearTimeout(timer) + inputRef.current?.focus(); + }, 50); + return () => clearTimeout(timer); } - }, [autoFocus]) + }, [autoFocus]); return ( @@ -127,7 +127,7 @@ export function SearchInput({ - ) + ); } export interface ComboboxItemProps { - label: string - description?: string - kind?: 'directory' | 'file' - leadingSlot?: ReactNode - selected?: boolean - active?: boolean - onPress: () => void - testID?: string + label: string; + description?: string; + kind?: "directory" | "file"; + leadingSlot?: ReactNode; + selected?: boolean; + active?: boolean; + onPress: () => void; + testID?: string; } export function ComboboxItem({ @@ -161,19 +161,19 @@ export function ComboboxItem({ onPress, testID, }: ComboboxItemProps): ReactElement { - const { theme } = useUnistyles() + const { theme } = useUnistyles(); const leadingContent = leadingSlot ? ( {leadingSlot} - ) : kind === 'directory' || kind === 'file' ? ( + ) : kind === "directory" || kind === "file" ? ( - {kind === 'directory' ? ( + {kind === "directory" ? ( ) : ( )} - ) : null + ) : null; return ( ) : null} - ) + ); } export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { @@ -211,7 +211,7 @@ export function ComboboxEmpty({ children }: { children: ReactNode }): ReactEleme {children} - ) + ); } export function Combobox({ @@ -221,207 +221,207 @@ export function Combobox({ renderOption, onSearchQueryChange, searchable = true, - placeholder = 'Search...', + placeholder = "Search...", searchPlaceholder, - emptyText = 'No options match your search.', + emptyText = "No options match your search.", allowCustomValue = false, - customValuePrefix = 'Use', + customValuePrefix = "Use", customValueDescription, customValueKind, - optionsPosition = 'below-search', - title = 'Select', + optionsPosition = "below-search", + title = "Select", open, onOpenChange, enableDismissOnClose, - desktopPlacement = 'top-start', + desktopPlacement = "top-start", desktopPreventInitialFlash = true, anchorRef, children, }: ComboboxProps): ReactElement { - const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition; const isDesktopAboveSearch = - !isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search' - const { height: windowHeight } = useWindowDimensions() - const bottomSheetRef = useRef(null) - const hasPresentedBottomSheetRef = useRef(false) - const snapPoints = useMemo(() => ['60%', '90%'], []) + !isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; + const { height: windowHeight } = useWindowDimensions(); + const bottomSheetRef = useRef(null); + const hasPresentedBottomSheetRef = useRef(false); + const snapPoints = useMemo(() => ["60%", "90%"], []); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>( - null - ) - const [referenceWidth, setReferenceWidth] = useState(null) - const [referenceTop, setReferenceTop] = useState(null) - const [referenceAtOrigin, setReferenceAtOrigin] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [activeIndex, setActiveIndex] = useState(-1) - const desktopOptionsScrollRef = useRef(null) + null, + ); + const [referenceWidth, setReferenceWidth] = useState(null); + const [referenceTop, setReferenceTop] = useState(null); + const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(-1); + const desktopOptionsScrollRef = useRef(null); - const isControlled = typeof open === 'boolean' - const [internalOpen, setInternalOpen] = useState(false) - const isOpen = isControlled ? open : internalOpen + const isControlled = typeof open === "boolean"; + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = isControlled ? open : internalOpen; const setOpen = useCallback( (nextOpen: boolean) => { if (!isControlled) { - setInternalOpen(nextOpen) + setInternalOpen(nextOpen); } - onOpenChange?.(nextOpen) + onOpenChange?.(nextOpen); }, - [isControlled, onOpenChange] - ) + [isControlled, onOpenChange], + ); const setSearchQueryWithCallback = useCallback( (nextQuery: string) => { - setSearchQuery(nextQuery) - onSearchQueryChange?.(nextQuery) + setSearchQuery(nextQuery); + onSearchQueryChange?.(nextQuery); }, - [onSearchQueryChange] - ) + [onSearchQueryChange], + ); const handleClose = useCallback(() => { - setOpen(false) - setSearchQueryWithCallback('') - }, [setOpen, setSearchQueryWithCallback]) + setOpen(false); + setSearchQueryWithCallback(""); + }, [setOpen, setSearchQueryWithCallback]); useEffect(() => { if (isOpen) { - setSearchQueryWithCallback('') + setSearchQueryWithCallback(""); } - }, [isOpen, setSearchQueryWithCallback]) + }, [isOpen, setSearchQueryWithCallback]); const collisionPadding = useMemo(() => { - const basePadding = 16 - if (Platform.OS !== 'android') return basePadding - const statusBarHeight = StatusBar.currentHeight ?? 0 - return Math.max(basePadding, statusBarHeight + basePadding) - }, []) + const basePadding = 16; + if (Platform.OS !== "android") return basePadding; + const statusBarHeight = StatusBar.currentHeight ?? 0; + return Math.max(basePadding, statusBarHeight + basePadding); + }, []); const middleware = useMemo( () => [ - floatingOffset(Platform.OS === 'web' ? 0 : 4), - ...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]), + floatingOffset(Platform.OS === "web" ? 0 : 4), + ...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), floatingSize({ padding: collisionPadding, apply({ availableWidth, availableHeight, rects }) { setAvailableSize((prev) => { - const next = { width: availableWidth, height: availableHeight } - if (!prev) return next - if (prev.width === next.width && prev.height === next.height) return prev - return next - }) + const next = { width: availableWidth, height: availableHeight }; + if (!prev) return next; + if (prev.width === next.width && prev.height === next.height) return prev; + return next; + }); setReferenceWidth((prev) => { - const next = rects.reference.width - if (prev === next) return prev - return next - }) + const next = rects.reference.width; + if (prev === next) return prev; + return next; + }); }, }), ], - [collisionPadding, isDesktopAboveSearch] - ) + [collisionPadding, isDesktopAboveSearch], + ); const { refs, floatingStyles, update } = useFloating({ - placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start', + placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", middleware, sameScrollView: false, elements: { reference: anchorRef.current ?? undefined, }, - }) + }); useEffect(() => { if (!isOpen || isMobile) { - setAvailableSize(null) - setReferenceWidth(null) - return + setAvailableSize(null); + setReferenceWidth(null); + return; } - const raf = requestAnimationFrame(() => void update()) - return () => cancelAnimationFrame(raf) - }, [desktopPlacement, isMobile, update, isOpen]) + const raf = requestAnimationFrame(() => void update()); + return () => cancelAnimationFrame(raf); + }, [desktopPlacement, isMobile, update, isOpen]); useEffect(() => { if (!isOpen || isMobile) { - setReferenceAtOrigin(false) - setReferenceTop(null) - return + setReferenceAtOrigin(false); + setReferenceTop(null); + return; } - const referenceEl = anchorRef.current + const referenceEl = anchorRef.current; if (!referenceEl) { - setReferenceAtOrigin(false) - setReferenceTop(null) - return + setReferenceAtOrigin(false); + setReferenceTop(null); + return; } const measure = () => { referenceEl.measureInWindow((x, y) => { - setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1) - setReferenceTop((prev) => (prev === y ? prev : y)) - }) - } + setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); + setReferenceTop((prev) => (prev === y ? prev : y)); + }); + }; - measure() - const raf = requestAnimationFrame(measure) - return () => cancelAnimationFrame(raf) - }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]) + measure(); + const raf = requestAnimationFrame(measure); + return () => cancelAnimationFrame(raf); + }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]); - const floatingTop = toNumericStyleValue(floatingStyles.top) - const floatingLeft = toNumericStyleValue(floatingStyles.left) + const floatingTop = toNumericStyleValue(floatingStyles.top); + const floatingLeft = toNumericStyleValue(floatingStyles.left); const desktopAboveSearchBottom = isDesktopAboveSearch && referenceTop !== null ? Math.max(windowHeight - referenceTop, collisionPadding) - : null + : null; const hasResolvedDesktopPosition = referenceWidth !== null && floatingLeft !== null && (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && - ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin) - const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition - const shouldUseDesktopFade = !desktopPreventInitialFlash + ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); + const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition; + const shouldUseDesktopFade = !desktopPreventInitialFlash; const desktopPositionStyle = isDesktopAboveSearch ? { left: floatingLeft ?? 0, bottom: desktopAboveSearchBottom ?? 0, } - : floatingStyles + : floatingStyles; useEffect(() => { - if (!isMobile) return + if (!isMobile) return; if (isOpen) { if (enableDismissOnClose === false && hasPresentedBottomSheetRef.current) { - bottomSheetRef.current?.snapToIndex(0) + bottomSheetRef.current?.snapToIndex(0); } else { - hasPresentedBottomSheetRef.current = true - bottomSheetRef.current?.present() + hasPresentedBottomSheetRef.current = true; + bottomSheetRef.current?.present(); } } else { if (enableDismissOnClose === false) { - bottomSheetRef.current?.close() + bottomSheetRef.current?.close(); } else { - bottomSheetRef.current?.dismiss() + bottomSheetRef.current?.dismiss(); } } - }, [enableDismissOnClose, isOpen, isMobile]) + }, [enableDismissOnClose, isOpen, isMobile]); const handleSheetChange = useCallback( (index: number) => { if (index === -1) { - handleClose() + handleClose(); } }, - [handleClose] - ) + [handleClose], + ); const renderBackdrop = useCallback( (props: React.ComponentProps) => ( ), - [] - ) + [], + ); - const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : '' - const sanitizedSearchValue = searchQuery.trim() + const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; + const sanitizedSearchValue = searchQuery.trim(); const showCustomOption = useMemo( () => shouldShowCustomComboboxOption({ @@ -430,8 +430,8 @@ export function Combobox({ searchable, allowCustomValue, }), - [allowCustomValue, options, searchQuery, searchable] - ) + [allowCustomValue, options, searchQuery, searchable], + ); const visibleOptions = useMemo( () => @@ -452,133 +452,133 @@ export function Combobox({ options, searchQuery, searchable, - ] - ) + ], + ); const orderedVisibleOptions = useMemo( () => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition), - [effectiveOptionsPosition, visibleOptions] - ) + [effectiveOptionsPosition, visibleOptions], + ); const pinDesktopOptionsToBottom = useCallback(() => { - if (isMobile || effectiveOptionsPosition !== 'above-search') { - return + if (isMobile || effectiveOptionsPosition !== "above-search") { + return; } - desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }) + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); requestAnimationFrame(() => { - desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }) - }) - }, [effectiveOptionsPosition, isMobile]) + desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); + }); + }, [effectiveOptionsPosition, isMobile]); const handleDesktopOptionsContentSizeChange = useCallback(() => { if (!isOpen) { - return + return; } - pinDesktopOptionsToBottom() - }, [isOpen, pinDesktopOptionsToBottom]) + pinDesktopOptionsToBottom(); + }, [isOpen, pinDesktopOptionsToBottom]); useEffect(() => { if (!isOpen) { - return + return; } - pinDesktopOptionsToBottom() - }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]) + pinDesktopOptionsToBottom(); + }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); useLayoutEffect(() => { if (!isOpen || isMobile) { - return + return; } - void update() - }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]) + void update(); + }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]); useEffect(() => { - if (!isOpen) return - if (!IS_WEB && isMobile) return + if (!isOpen) return; + if (!IS_WEB && isMobile) return; if (orderedVisibleOptions.length === 0) { - setActiveIndex(-1) - return + setActiveIndex(-1); + return; } const fallbackIndex = getComboboxFallbackIndex( orderedVisibleOptions.length, - effectiveOptionsPosition - ) + effectiveOptionsPosition, + ); if (normalizedSearch) { - setActiveIndex(fallbackIndex) - return + setActiveIndex(fallbackIndex); + return; } - const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value) - setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex) - }, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions]) + const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); + setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex); + }, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions]); const handleSelect = useCallback( (id: string) => { - onSelect(id) - handleClose() + onSelect(id); + handleClose(); }, - [handleClose, onSelect] - ) + [handleClose, onSelect], + ); const handleSubmitSearch = useCallback(() => { if (showCustomOption) { - handleSelect(sanitizedSearchValue) + handleSelect(sanitizedSearchValue); } - }, [handleSelect, sanitizedSearchValue, showCustomOption]) + }, [handleSelect, sanitizedSearchValue, showCustomOption]); const handleDesktopKey = useCallback( - (key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => { - if (!isOpen) return - if (!IS_WEB && isMobile) return + (key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => { + if (!isOpen) return; + if (!IS_WEB && isMobile) return; - if (key === 'ArrowDown' || key === 'ArrowUp') { - event?.preventDefault() + if (key === "ArrowDown" || key === "ArrowUp") { + event?.preventDefault(); setActiveIndex((currentIndex) => getNextActiveIndex({ currentIndex, itemCount: orderedVisibleOptions.length, key, - }) - ) - return + }), + ); + return; } - if (key === 'Enter') { - if (orderedVisibleOptions.length === 0) return - event?.preventDefault() + if (key === "Enter") { + if (orderedVisibleOptions.length === 0) return; + event?.preventDefault(); const index = - activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0 - handleSelect(orderedVisibleOptions[index]!.id) - return + activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; + handleSelect(orderedVisibleOptions[index]!.id); + return; } - if (key === 'Escape') { - event?.preventDefault() - handleClose() + if (key === "Escape") { + event?.preventDefault(); + handleClose(); } }, - [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions] - ) + [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions], + ); useEffect(() => { - if (!IS_WEB || !isOpen) return + if (!IS_WEB || !isOpen) return; const handler = (event: KeyboardEvent) => { - const key = event.key - if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') { - return + const key = event.key; + if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { + return; } - handleDesktopKey(key, event) - } + handleDesktopKey(key, event); + }; // react-native-web's TextInput can stop propagation on key events, so listen in capture phase. - window.addEventListener('keydown', handler, true) + window.addEventListener("keydown", handler, true); return () => { - window.removeEventListener('keydown', handler, true) - } - }, [handleDesktopKey, isOpen]) + window.removeEventListener("keydown", handler, true); + }; + }, [handleDesktopKey, isOpen]); const searchInput = ( - ) + ); const optionsList = ( <> {orderedVisibleOptions.length > 0 ? ( - orderedVisibleOptions.map((opt, index) => ( + orderedVisibleOptions.map((opt, index) => renderOption ? ( {renderOption({ @@ -613,23 +613,23 @@ export function Combobox({ active={index === activeIndex} onPress={() => handleSelect(opt.id)} /> - ) - )) + ), + ) ) : ( {emptyText} )} - ) + ); const defaultContent = ( <> - {effectiveOptionsPosition === 'above-search' ? optionsList : null} + {effectiveOptionsPosition === "above-search" ? optionsList : null} {searchable ? searchInput : null} - {effectiveOptionsPosition === 'below-search' ? optionsList : null} + {effectiveOptionsPosition === "below-search" ? optionsList : null} - ) + ); - const content = children ?? defaultContent + const content = children ?? defaultContent; if (isMobile) { return ( @@ -658,10 +658,10 @@ export function Combobox({ {content} - ) + ); } - if (!isOpen) return <> + if (!isOpen) return <>; return ( @@ -674,13 +674,13 @@ export function Combobox({ style={[ styles.desktopContainer, { - position: 'absolute', + position: "absolute", minWidth: referenceWidth ?? 200, maxWidth: 400, }, desktopPositionStyle, shouldHideDesktopContent ? { opacity: 0 } : null, - typeof availableSize?.height === 'number' + typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null, ]} @@ -699,7 +699,7 @@ export function Combobox({ ) : ( <> - {effectiveOptionsPosition === 'above-search' ? ( + {effectiveOptionsPosition === "above-search" ? ( ) : null} {searchable ? searchInput : null} - {effectiveOptionsPosition === 'below-search' ? ( + {effectiveOptionsPosition === "below-search" ? ( - ) + ); } const styles = StyleSheet.create((theme) => ({ searchInputContainer: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", borderWidth: 1, borderColor: theme.colors.border, backgroundColor: theme.colors.surface1, @@ -754,8 +754,8 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, }, comboboxItem: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", minHeight: 36, gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], @@ -779,9 +779,9 @@ const styles = StyleSheet.create((theme) => ({ }, comboboxItemTrailingSlot: { width: 16, - alignItems: 'center', - justifyContent: 'center', - marginLeft: 'auto', + alignItems: "center", + justifyContent: "center", + marginLeft: "auto", }, comboboxItemContent: { flex: 1, @@ -789,8 +789,8 @@ const styles = StyleSheet.create((theme) => ({ }, comboboxItemLeadingSlot: { width: 16, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", }, comboboxItemLabel: { fontSize: theme.fontSize.sm, @@ -809,8 +809,8 @@ const styles = StyleSheet.create((theme) => ({ }, bottomSheetBackground: { backgroundColor: theme.colors.surface0, - borderTopLeftRadius: theme.borderRadius['2xl'], - borderTopRightRadius: theme.borderRadius['2xl'], + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], }, bottomSheetHandle: { backgroundColor: theme.colors.palette.zinc[600], @@ -823,7 +823,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, - textAlign: 'left', + textAlign: "left", }, comboboxScrollContent: { paddingBottom: theme.spacing[8], @@ -834,7 +834,7 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, }, desktopBackdrop: { - position: 'absolute', + position: "absolute", top: 0, right: 0, bottom: 0, @@ -845,13 +845,13 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.border, - shadowColor: '#000', + shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, maxHeight: 400, - overflow: 'hidden', + overflow: "hidden", }, desktopScroll: { flexShrink: 1, @@ -862,6 +862,6 @@ const styles = StyleSheet.create((theme) => ({ }, desktopScrollContentAboveSearch: { flexGrow: 1, - justifyContent: 'flex-end', + justifyContent: "flex-end", }, -})) +})); diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 8c0f4df79..4448d7a92 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -29,11 +29,7 @@ import { import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { Check, CheckCircle } from "lucide-react-native"; -import { - BottomSheetBackdrop, - BottomSheetModal, - BottomSheetScrollView, -} from "@gorhom/bottom-sheet"; +import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; @@ -88,7 +84,7 @@ function useControllableOpenState({ if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, - [isControlled, onOpenChange] + [isControlled, onOpenChange], ); return [value, setValue]; } @@ -120,8 +116,7 @@ function computePosition({ // Calculate available space const spaceTop = triggerRect.y - displayArea.y; - const spaceBottom = - displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); + const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); // Flip if needed let actualPlacement = placement; @@ -163,7 +158,7 @@ function computePosition({ x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!)); y = Math.max( displayArea.y + padding, - Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!) + Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!), ); return { x, y, actualPlacement }; @@ -226,7 +221,7 @@ export function ContextMenu({ anchorRect, setAnchorRect, }), - [anchorRect, isOpen, setAnchorRect, setIsOpen] + [anchorRect, isOpen, setAnchorRect, setIsOpen], ); return {children}; @@ -258,8 +253,7 @@ export function ContextMenuTrigger({ const ctx = useContextMenuContext("ContextMenuTrigger"); const shouldEnableOnThisPlatform = - enabled && - (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile); + enabled && (Platform.OS === "web" ? enabledOnWeb : enabledOnMobile); const openAtEvent = useCallback( (event: unknown) => { @@ -279,7 +273,7 @@ export function ContextMenuTrigger({ }); ctx.setOpen(true); }, - [ctx, disabled, shouldEnableOnThisPlatform] + [ctx, disabled, shouldEnableOnThisPlatform], ); const handleRef = useCallback( @@ -287,7 +281,7 @@ export function ContextMenuTrigger({ assignRef(ctx.triggerRef, node); assignRef(triggerRef, node); }, - [ctx.triggerRef, triggerRef] + [ctx.triggerRef, triggerRef], ); return ( @@ -380,19 +374,14 @@ export function ContextMenuContent({ handleClose(); } }, - [handleClose] + [handleClose], ); const renderSheetBackdrop = useCallback( (props: ComponentProps) => ( - + ), - [] + [], ); // Measure trigger when opening (fallback) and capture point anchors. @@ -468,7 +457,7 @@ export function ContextMenuContent({ const { width: w, height: h } = event.nativeEvent.layout; setContentSize({ width: w, height: h }); }, - [] + [], ); if (useMobileSheet) { @@ -543,7 +532,11 @@ export function ContextMenuContent({ }, ]} > - + {children} @@ -564,7 +557,13 @@ export function ContextMenuLabel({ ); } -export function ContextMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement { +export function ContextMenuSeparator({ + style, + testID, +}: { + style?: ViewStyle; + testID?: string; +}): ReactElement { return ; } @@ -638,7 +637,10 @@ export function ContextMenuItem({ } const trailingContent = - trailing ?? (!showSelectedCheck && selected ? : null); + trailing ?? + (!showSelectedCheck && selected ? ( + + ) : null); return ( [ styles.item, - selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null, - selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null, + selected + ? selectedVariant === "accent" + ? styles.itemSelectedAccent + : styles.itemSelected + : null, + selected && (hovered || pressed) && selectedVariant !== "accent" + ? styles.itemSelectedInteractive + : null, isDisabled ? styles.itemDisabled : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null, pressed && !isDisabled ? styles.itemPressed : null, @@ -684,7 +692,9 @@ export function ContextMenuItem({ numberOfLines={2} style={[ styles.itemDescription, - selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null, + selected && selectedVariant === "accent" + ? styles.itemDescriptionSelectedAccent + : null, ]} > {description} diff --git a/packages/app/src/components/ui/dropdown-menu-floating.ts b/packages/app/src/components/ui/dropdown-menu-floating.ts index 6eefd1f1d..5d07b2b59 100644 --- a/packages/app/src/components/ui/dropdown-menu-floating.ts +++ b/packages/app/src/components/ui/dropdown-menu-floating.ts @@ -170,7 +170,6 @@ export function useDropdownFloating({ }, []); const update = useCallback(async () => { - if (!referenceEl || !floatingElRef.current) { return; } @@ -209,7 +208,6 @@ export function useDropdownFloating({ }, []); useEffect(() => { - if (!open) { setGeometry(null); setFloatingReady(false); diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index 4d219ab55..04c3a9886 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -74,7 +74,7 @@ function useControllableOpenState({ if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, - [isControlled, onOpenChange] + [isControlled, onOpenChange], ); return [value, setValue]; } @@ -146,7 +146,10 @@ function computePosition({ // Constrain to screen const padding = 8; x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!)); - y = Math.max(displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!)); + y = Math.max( + displayArea.y + padding, + Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!), + ); return { x, y, actualPlacement }; } @@ -174,16 +177,14 @@ export function DropdownMenu({ setOpen: setIsOpen, triggerRef, }), - [isOpen, setIsOpen] + [isOpen, setIsOpen], ); return {children}; } type TriggerState = { pressed: boolean; hovered: boolean; open: boolean }; -type TriggerStyleProp = - | StyleProp - | ((state: TriggerState) => StyleProp); +type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); interface DropdownMenuTriggerProps extends Omit { style?: TriggerStyleProp; @@ -315,10 +316,13 @@ export function DropdownMenuContent({ setPosition({ x, y: result.y }); }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]); - const handleContentLayout = useCallback((event: { nativeEvent: { layout: { width: number; height: number } } }) => { - const { width: w, height: h } = event.nativeEvent.layout; - setContentSize({ width: w, height: h }); - }, []); + const handleContentLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width: w, height: h } = event.nativeEvent.layout; + setContentSize({ width: w, height: h }); + }, + [], + ); if (!open) return null; @@ -363,7 +367,11 @@ export function DropdownMenuContent({ }, ]} > - + {children} @@ -384,7 +392,13 @@ export function DropdownMenuLabel({ ); } -export function DropdownMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement { +export function DropdownMenuSeparator({ + style, + testID, +}: { + style?: ViewStyle; + testID?: string; +}): ReactElement { return ; } @@ -464,7 +478,10 @@ export function DropdownMenuItem({ } const trailingContent = - trailing ?? (!showSelectedCheck && selected ? : null); + trailing ?? + (!showSelectedCheck && selected ? ( + + ) : null); return ( [ styles.item, - selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null, + selected + ? selectedVariant === "accent" + ? styles.itemSelectedAccent + : styles.itemSelected + : null, selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null, @@ -512,7 +533,9 @@ export function DropdownMenuItem({ numberOfLines={2} style={[ styles.itemDescription, - selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null, + selected && selectedVariant === "accent" + ? styles.itemDescriptionSelectedAccent + : null, ]} > {description} diff --git a/packages/app/src/components/ui/segmented-control.tsx b/packages/app/src/components/ui/segmented-control.tsx index d2ac85add..0af7efe5f 100644 --- a/packages/app/src/components/ui/segmented-control.tsx +++ b/packages/app/src/components/ui/segmented-control.tsx @@ -5,10 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; type SegmentedControlSize = "sm" | "md"; -type SegmentedControlIconRenderer = (props: { - color: string; - size: number; -}) => ReactNode; +type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode; export type SegmentedControlOption = { value: T; diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 6b34236fe..b762b67bb 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -59,7 +59,7 @@ function useTooltipContext(componentName: string): TooltipContextValue { function composeEventHandlers( original?: (event: E) => void, - injected?: (event: E) => void + injected?: (event: E) => void, ): (event: E) => void { return (event: E) => { original?.(event); @@ -94,7 +94,7 @@ function useControllableOpenState({ if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, - [isControlled, onOpenChange] + [isControlled, onOpenChange], ); return [value, setValue]; } @@ -125,11 +125,9 @@ function computePosition({ const { width: contentWidth, height: contentHeight } = contentSize; const spaceTop = triggerRect.y - displayArea.y; - const spaceBottom = - displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); + const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); const spaceLeft = triggerRect.x - displayArea.x; - const spaceRight = - displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width); + const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width); let actualSide = side; if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { @@ -184,13 +182,10 @@ function computePosition({ } const padding = 8; - x = Math.max( - padding, - Math.min(displayArea.width - contentWidth - padding, x) - ); + x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x)); y = Math.max( displayArea.y + padding, - Math.min(displayArea.y + displayArea.height - contentHeight - padding, y) + Math.min(displayArea.y + displayArea.height - contentHeight - padding, y), ); return { x, y, actualSide }; @@ -234,7 +229,7 @@ export function Tooltip({ enabled, delayDuration, }), - [isOpen, setIsOpen, enabled, delayDuration] + [isOpen, setIsOpen, enabled, delayDuration], ); return {children}; @@ -296,7 +291,7 @@ export function TooltipTrigger({ onHoverIn?.(e); scheduleOpen(); }, - [onHoverIn, scheduleOpen] + [onHoverIn, scheduleOpen], ); const handleHoverOut = useCallback( @@ -304,7 +299,7 @@ export function TooltipTrigger({ onHoverOut?.(e); close(); }, - [onHoverOut, close] + [onHoverOut, close], ); const handleFocus = useCallback( @@ -314,7 +309,7 @@ export function TooltipTrigger({ clearOpenTimer(); ctx.setOpen(true); }, - [clearOpenTimer, ctx, disabled, onFocus] + [clearOpenTimer, ctx, disabled, onFocus], ); const handleBlur = useCallback( @@ -322,7 +317,7 @@ export function TooltipTrigger({ onBlur?.(e); close(); }, - [close, onBlur] + [close, onBlur], ); const handlePress = useCallback( @@ -330,7 +325,7 @@ export function TooltipTrigger({ onPress?.(e); close(); }, - [close, onPress] + [close, onPress], ); const triggerProps = { @@ -383,11 +378,7 @@ export function TooltipTrigger({ } return ( - + {children} ); @@ -412,9 +403,7 @@ export function TooltipContent({ const ctx = useTooltipContext("TooltipContent"); const bottomSheetInternal = useBottomSheetModalInternal(true); const [triggerRect, setTriggerRect] = useState(null); - const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>( - null - ); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); useEffect(() => { @@ -425,8 +414,7 @@ export function TooltipContent({ return; } - const statusBarHeight = - Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; measureElement(ctx.triggerRef.current).then((rect) => { @@ -459,7 +447,7 @@ export function TooltipContent({ const { width, height } = event.nativeEvent.layout; setContentSize({ width, height }); }, - [] + [], ); if (!ctx.open || !ctx.enabled) return null; diff --git a/packages/app/src/components/use-bottom-anchor-controller.test.ts b/packages/app/src/components/use-bottom-anchor-controller.test.ts index 7844e5c4d..3b710c730 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.test.ts +++ b/packages/app/src/components/use-bottom-anchor-controller.test.ts @@ -17,7 +17,7 @@ function createMeasurementState( offsetY: number; viewportMeasuredForKey: string | null; contentMeasuredForKey: string | null; - }> + }>, ) { return { containerKey: "scroll-view", @@ -119,11 +119,10 @@ function createDriverHarness(input?: { agentId: "agent-1", authoritativeReady: input?.authoritativeReady ?? true, renderStrategy: "forward-stream", - transportBehavior: - input?.transportBehavior ?? { - verificationDelayFrames: 0, - verificationRetryMode: "rescroll", - }, + transportBehavior: input?.transportBehavior ?? { + verificationDelayFrames: 0, + verificationRetryMode: "rescroll", + }, measurementState, nearBottom: input?.isNearBottom ?? true, }; @@ -166,7 +165,7 @@ describe("deriveBottomAnchorBlockedReason", () => { isAuthoritativeHistoryReady: false, measurementState: createMeasurementState(), pendingVerificationRequestId: null, - }) + }), ).toBe("waiting_for_history_readiness"); expect( @@ -178,7 +177,7 @@ describe("deriveBottomAnchorBlockedReason", () => { viewportMeasuredForKey: "scroll-view", }), pendingVerificationRequestId: null, - }) + }), ).toBe("waiting_for_measurable_content"); expect( @@ -192,7 +191,7 @@ describe("deriveBottomAnchorBlockedReason", () => { contentMeasuredForKey: "scroll-view", }), pendingVerificationRequestId: pendingRequest.id, - }) + }), ).toBe("waiting_for_post_layout_verification"); }); }); @@ -440,7 +439,7 @@ describe("bottom anchor controller driver", () => { harness.context.measurementState.offsetY = Math.max( 0, harness.context.measurementState.contentHeight - - harness.context.measurementState.viewportHeight + harness.context.measurementState.viewportHeight, ); harness.context.nearBottom = true; }); @@ -575,7 +574,7 @@ describe("controller helper predicates", () => { viewportMeasuredForKey: "scroll-view", contentMeasuredForKey: "scroll-view", }), - }) + }), ).toBe("waiting_for_measurable_viewport"); }); @@ -590,7 +589,7 @@ describe("controller helper predicates", () => { viewportMeasuredForKey: "scroll-view", contentMeasuredForKey: "scroll-view", }), - }) + }), ).toBe("waiting_for_history_readiness"); expect( @@ -603,7 +602,7 @@ describe("controller helper predicates", () => { viewportMeasuredForKey: "scroll-view", contentMeasuredForKey: "scroll-view", }), - }) + }), ).toBeNull(); }); @@ -615,7 +614,7 @@ describe("controller helper predicates", () => { mode, previousContentHeight: 1000, contentHeight: 1100, - }) + }), ).toBe(false); expect( __private__.shouldRestickOnViewportChange({ @@ -624,7 +623,7 @@ describe("controller helper predicates", () => { viewportWidth: 640, previousViewportHeight: 400, viewportHeight: 360, - }) + }), ).toBe(false); }); @@ -637,7 +636,7 @@ describe("controller helper predicates", () => { hasPendingRequest: true, hasPendingVerification: false, hasUnverifiedStickyMeasurementChange: false, - }) + }), ).toBe(false); expect( @@ -648,7 +647,7 @@ describe("controller helper predicates", () => { hasPendingRequest: false, hasPendingVerification: true, hasUnverifiedStickyMeasurementChange: false, - }) + }), ).toBe(false); expect( @@ -659,7 +658,7 @@ describe("controller helper predicates", () => { hasPendingRequest: false, hasPendingVerification: false, hasUnverifiedStickyMeasurementChange: true, - }) + }), ).toBe(false); expect( @@ -670,7 +669,7 @@ describe("controller helper predicates", () => { hasPendingRequest: false, hasPendingVerification: false, hasUnverifiedStickyMeasurementChange: false, - }) + }), ).toBe(true); }); @@ -683,7 +682,7 @@ describe("controller helper predicates", () => { hasPendingRequest: false, hasPendingVerification: false, hasUnverifiedStickyMeasurementChange: true, - }) + }), ).toBe(true); }); }); diff --git a/packages/app/src/components/use-bottom-anchor-controller.ts b/packages/app/src/components/use-bottom-anchor-controller.ts index db98ac849..55457bebe 100644 --- a/packages/app/src/components/use-bottom-anchor-controller.ts +++ b/packages/app/src/components/use-bottom-anchor-controller.ts @@ -222,9 +222,7 @@ function deriveRetryDisposition(input: { if (input.mode !== "sticky-bottom" || input.retries >= MAX_VERIFICATION_RETRIES) { return "fail"; } - return input.verificationRetryMode === "recheck" - ? "retry-verify" - : "retry-scroll"; + return input.verificationRetryMode === "recheck" ? "retry-verify" : "retry-scroll"; } function shouldRequireRouteRequestConfirmation(input: { @@ -235,10 +233,7 @@ function shouldRequireRouteRequestConfirmation(input: { if (!input.request) { return false; } - if ( - input.request.reason !== "initial-entry" && - input.request.reason !== "resume" - ) { + if (input.request.reason !== "initial-entry" && input.request.reason !== "resume") { return false; } if (input.measurementState.containerKey !== "web-partial-virtualized") { @@ -248,12 +243,11 @@ function shouldRequireRouteRequestConfirmation(input: { } function getDetailedMeasurementState( - measurementState: ControllerMeasurementState + measurementState: ControllerMeasurementState, ): Record { const distanceFromBottom = Math.max( 0, - measurementState.contentHeight - - (measurementState.offsetY + measurementState.viewportHeight) + measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight), ); return { containerKey: measurementState.containerKey, @@ -268,7 +262,7 @@ function getDetailedMeasurementState( } function createBottomAnchorControllerDriver( - input: CreateBottomAnchorControllerDriverInput + input: CreateBottomAnchorControllerDriverInput, ): BottomAnchorControllerDriver { let requestSequence = 0; let mode: BottomAnchorMode = "sticky-bottom"; @@ -285,8 +279,7 @@ function createBottomAnchorControllerDriver( const measurementState = input.getMeasurementState(); const distanceFromBottom = Math.max( 0, - measurementState.contentHeight - - (measurementState.offsetY + measurementState.viewportHeight) + measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight), ); return { agentId: input.getAgentId(), @@ -355,34 +348,27 @@ function createBottomAnchorControllerDriver( setBlockedReason(null); }; - const deriveDriverBlockedReason = ( - measurementState: ControllerMeasurementState - ) => + const deriveDriverBlockedReason = (measurementState: ControllerMeasurementState) => deriveBottomAnchorBlockedReason({ pendingRequest, isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), measurementState, pendingVerificationRequestId: - verificationHandle !== null ? pendingVerification?.requestId ?? null : null, + verificationHandle !== null ? (pendingVerification?.requestId ?? null) : null, }); - const scheduleVerification = ( - attemptContext: AttemptContext, - delayFramesOverride?: number - ) => { + const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => { const scheduledMeasurementState = input.getMeasurementState(); if (verificationHandle) { input.cancelFrame(verificationHandle); } verificationHandle = input.scheduleFrame({ kind: "verification", - delayFrames: - delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames, + delayFrames: delayFramesOverride ?? input.getTransportBehavior().verificationDelayFrames, callback: () => { verificationHandle = null; const currentRequest = pendingRequest; - const isRequestAttempt = - currentRequest && attemptContext.requestId === currentRequest.id; + const isRequestAttempt = currentRequest && attemptContext.requestId === currentRequest.id; const measurementState = input.getMeasurementState(); const verificationBlockedReason = deriveVerificationBlockedReason({ isAuthoritativeHistoryReady: input.getIsAuthoritativeHistoryReady(), @@ -401,8 +387,7 @@ function createBottomAnchorControllerDriver( : deriveRetryDisposition({ mode, retries: attemptContext.retries, - verificationRetryMode: - input.getTransportBehavior().verificationRetryMode, + verificationRetryMode: input.getTransportBehavior().verificationRetryMode, }); if (verifiedNearBottom) { @@ -421,7 +406,7 @@ function createBottomAnchorControllerDriver( setBlockedReason("waiting_for_post_layout_verification"); scheduleVerification( pendingVerification, - WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES + WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES, ); return; } @@ -454,9 +439,7 @@ function createBottomAnchorControllerDriver( } pendingVerification = null; - setBlockedReason( - isRequestAttempt ? "waiting_for_post_layout_verification" : null - ); + setBlockedReason(isRequestAttempt ? "waiting_for_post_layout_verification" : null); }, }); }; @@ -485,7 +468,7 @@ function createBottomAnchorControllerDriver( | "scroll_near_bottom_change" | "history_readiness_change" | "manual_reevaluate" - | "retry_scroll" + | "retry_scroll", ) => { if (attemptHandle) { return; @@ -501,14 +484,9 @@ function createBottomAnchorControllerDriver( const shouldAttemptForPendingRequest = pendingRequest !== null && nextBlockedReason === null; const shouldAttemptForStickyVerification = - mode === "sticky-bottom" && - pendingVerification !== null && - nextBlockedReason === null; + mode === "sticky-bottom" && pendingVerification !== null && nextBlockedReason === null; - if ( - !shouldAttemptForPendingRequest && - !shouldAttemptForStickyVerification - ) { + if (!shouldAttemptForPendingRequest && !shouldAttemptForStickyVerification) { return; } @@ -534,7 +512,7 @@ function createBottomAnchorControllerDriver( setModeInternal( "requestKey" in request ? "sticky-bottom" - : __private__.deriveModeForLocalRequest({ reason: request.reason }) + : __private__.deriveModeForLocalRequest({ reason: request.reason }), ); evaluate(request.reason === "jump-to-bottom", "request_created"); }; @@ -654,11 +632,7 @@ function createBottomAnchorControllerDriver( this.detachByUser(); return; } - if ( - mode === "sticky-bottom" && - !nextIsNearBottom && - hasUnverifiedStickyMeasurementChange - ) { + if (mode === "sticky-bottom" && !nextIsNearBottom && hasUnverifiedStickyMeasurementChange) { if (!pendingRequest && !pendingVerification) { pendingVerification = { requestId: null, retries: 0 }; } @@ -727,8 +701,7 @@ export const __private__ = { hasPendingVerification: boolean; hasUnverifiedStickyMeasurementChange: boolean; }): boolean { - const scrolledAwayIntentionally = - Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX; + const scrolledAwayIntentionally = Math.abs(input.scrollDelta) >= USER_SCROLL_AWAY_DELTA_PX; return ( input.mode === "sticky-bottom" && !input.nextIsNearBottom && @@ -819,10 +792,7 @@ export function useBottomAnchorController(input: { }) { driverRef.current?.handleViewportMetricsChange(params); }, - handleContentSizeChange(params: { - previousContentHeight: number; - contentHeight: number; - }) { + handleContentSizeChange(params: { previousContentHeight: number; contentHeight: number }) { driverRef.current?.handleContentSizeChange(params); }, prepareForStickyViewportChange() { @@ -831,10 +801,7 @@ export function useBottomAnchorController(input: { prepareForStickyContentChange() { driverRef.current?.prepareForStickyContentChange(); }, - handleScrollNearBottomChange(params: { - nextIsNearBottom: boolean; - scrollDelta: number; - }) { + handleScrollNearBottomChange(params: { nextIsNearBottom: boolean; scrollDelta: number }) { driverRef.current?.handleScrollNearBottomChange(params); }, reevaluate(animated = false) { diff --git a/packages/app/src/components/voice-button.tsx b/packages/app/src/components/voice-button.tsx index 3fe678e4b..ed6c22703 100644 --- a/packages/app/src/components/voice-button.tsx +++ b/packages/app/src/components/voice-button.tsx @@ -1,16 +1,16 @@ -import { Pressable, View, Text, Animated } from 'react-native'; -import { useEffect, useRef } from 'react'; -import { StyleSheet } from 'react-native-unistyles'; +import { Pressable, View, Text, Animated } from "react-native"; +import { useEffect, useRef } from "react"; +import { StyleSheet } from "react-native-unistyles"; interface VoiceButtonProps { - state: 'idle' | 'recording' | 'processing' | 'playing'; + state: "idle" | "recording" | "processing" | "playing"; onPress: () => void; disabled?: boolean; } const styles = StyleSheet.create((theme) => ({ container: { - alignItems: 'center', + alignItems: "center", gap: theme.spacing[4], }, pressable: { @@ -23,9 +23,9 @@ const styles = StyleSheet.create((theme) => ({ width: 80, height: 80, borderRadius: theme.borderRadius.full, - alignItems: 'center', - justifyContent: 'center', - shadowColor: '#000', + alignItems: "center", + justifyContent: "center", + shadowColor: "#000", shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, @@ -64,7 +64,7 @@ const styles = StyleSheet.create((theme) => ({ height: 6, backgroundColor: theme.colors.primaryForeground, borderRadius: theme.borderRadius.full, - position: 'absolute', + position: "absolute", }, processingDotTop: { top: 0, @@ -80,8 +80,8 @@ const styles = StyleSheet.create((theme) => ({ }, // Playing icon playingIconContainer: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[1], }, playingBar: { @@ -104,10 +104,10 @@ const styles = StyleSheet.create((theme) => ({ micContainer: { width: 24, height: 32, - position: 'relative', + position: "relative", }, micCapsule: { - position: 'absolute', + position: "absolute", bottom: 0, left: 4, width: 16, @@ -117,7 +117,7 @@ const styles = StyleSheet.create((theme) => ({ borderTopRightRadius: 999, }, micBase: { - position: 'absolute', + position: "absolute", bottom: 0, left: 0, width: 24, @@ -131,7 +131,7 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro const pulseAnim = useRef(new Animated.Value(1)).current; useEffect(() => { - if (state === 'recording') { + if (state === "recording") { Animated.loop( Animated.sequence([ Animated.timing(pulseAnim, { @@ -144,7 +144,7 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro duration: 800, useNativeDriver: true, }), - ]) + ]), ).start(); } else { pulseAnim.setValue(1); @@ -153,11 +153,11 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro const getButtonStyle = () => { switch (state) { - case 'recording': + case "recording": return styles.buttonRecording; - case 'processing': + case "processing": return styles.buttonProcessing; - case 'playing': + case "playing": return styles.buttonPlaying; default: return styles.buttonIdle; @@ -166,9 +166,9 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro const getIcon = () => { switch (state) { - case 'recording': + case "recording": return ; - case 'processing': + case "processing": return ( @@ -176,7 +176,7 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro ); - case 'playing': + case "playing": return ( @@ -196,14 +196,14 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro const getLabel = () => { switch (state) { - case 'recording': - return 'Recording...'; - case 'processing': - return 'Processing...'; - case 'playing': - return 'Playing...'; + case "recording": + return "Recording..."; + case "processing": + return "Processing..."; + case "playing": + return "Playing..."; default: - return 'Tap to speak'; + return "Tap to speak"; } }; @@ -214,7 +214,9 @@ export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonPro disabled={disabled} style={disabled ? styles.pressableDisabled : styles.pressable} > - + {getIcon()} diff --git a/packages/app/src/components/voice-compact-indicator.tsx b/packages/app/src/components/voice-compact-indicator.tsx index e70a8da4f..e025cfef9 100644 --- a/packages/app/src/components/voice-compact-indicator.tsx +++ b/packages/app/src/components/voice-compact-indicator.tsx @@ -7,13 +7,7 @@ import { useVoice, useVoiceTelemetry } from "@/contexts/voice-context"; export function VoiceCompactIndicator() { const { theme } = useUnistyles(); const { volume, isSpeaking } = useVoiceTelemetry(); - const { - isVoiceMode, - isVoiceSwitching, - isMuted, - toggleMute, - stopVoice, - } = useVoice(); + const { isVoiceMode, isVoiceSwitching, isMuted, toggleMute, stopVoice } = useVoice(); if (!isVoiceMode) { return null; } @@ -36,10 +30,7 @@ export function VoiceCompactIndicator() { disabled={isVoiceSwitching} accessibilityRole="button" accessibilityLabel={isMuted ? "Unmute voice" : "Mute voice"} - style={[ - styles.muteButton, - isVoiceSwitching ? styles.buttonDisabled : undefined, - ]} + style={[styles.muteButton, isVoiceSwitching ? styles.buttonDisabled : undefined]} hitSlop={8} > {isMuted ? ( @@ -59,10 +50,7 @@ export function VoiceCompactIndicator() { disabled={isVoiceSwitching} accessibilityRole="button" accessibilityLabel="Disable realtime voice mode" - style={[ - styles.stopButton, - isVoiceSwitching ? styles.buttonDisabled : undefined, - ]} + style={[styles.stopButton, isVoiceSwitching ? styles.buttonDisabled : undefined]} hitSlop={8} > {isVoiceSwitching ? ( diff --git a/packages/app/src/components/voice-panel.tsx b/packages/app/src/components/voice-panel.tsx index 88630917f..d2c0836ea 100644 --- a/packages/app/src/components/voice-panel.tsx +++ b/packages/app/src/components/voice-panel.tsx @@ -9,14 +9,9 @@ export function VoicePanel() { const { theme } = useUnistyles(); const daemons = useHosts(); const { volume, isSpeaking } = useVoiceTelemetry(); - const { - isMuted, - stopVoice, - toggleMute, - activeServerId, - } = useVoice(); + const { isMuted, stopVoice, toggleMute, activeServerId } = useVoice(); const hostLabel = activeServerId - ? daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null + ? (daemons.find((daemon) => daemon.serverId === activeServerId)?.label ?? null) : null; const hostSuffix = hostLabel ? ` (${hostLabel})` : ""; @@ -38,10 +33,7 @@ export function VoicePanel() { onPress={toggleMute} accessibilityRole="button" accessibilityLabel={`${isMuted ? "Unmute voice" : "Mute voice"}${hostSuffix}`} - style={[ - styles.iconButton, - isMuted && styles.iconButtonMuted, - ]} + style={[styles.iconButton, isMuted && styles.iconButtonMuted]} > 0.001 ? 1 : line1Pulse.value), + height: currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line1Pulse.value), opacity: baseOpacity + volumeBoost, }; }); @@ -122,11 +119,10 @@ export function VolumeMeter({ const isActive = isSpeaking; const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; const currentVolume = isMuted ? 0 : animatedVolume.value; - const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 1.05); + const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 1.05; const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; return { - height: - currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line2Pulse.value), + height: currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line2Pulse.value), opacity: baseOpacity + volumeBoost, }; }); @@ -135,11 +131,10 @@ export function VolumeMeter({ const isActive = isSpeaking; const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; const currentVolume = isMuted ? 0 : animatedVolume.value; - const currentHeight = MIN_HEIGHT + (MAX_HEIGHT * currentVolume * 0.9); + const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 0.9; const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3; return { - height: - currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line3Pulse.value), + height: currentHeight * (isMuted || currentVolume > 0.001 ? 1 : line3Pulse.value), opacity: baseOpacity + volumeBoost, }; }); @@ -147,27 +142,15 @@ export function VolumeMeter({ return ( ); diff --git a/packages/app/src/components/web-desktop-scrollbar.math.ts b/packages/app/src/components/web-desktop-scrollbar.math.ts index fbb6d7683..05a7f3259 100644 --- a/packages/app/src/components/web-desktop-scrollbar.math.ts +++ b/packages/app/src/components/web-desktop-scrollbar.math.ts @@ -20,14 +20,10 @@ export type VerticalScrollbarGeometry = { }; export function computeVerticalScrollbarGeometry( - input: VerticalScrollbarGeometryInput + input: VerticalScrollbarGeometryInput, ): VerticalScrollbarGeometry { - const viewportSize = Number.isFinite(input.viewportSize) - ? Math.max(0, input.viewportSize) - : 0; - const contentSize = Number.isFinite(input.contentSize) - ? Math.max(0, input.contentSize) - : 0; + const viewportSize = Number.isFinite(input.viewportSize) ? Math.max(0, input.viewportSize) : 0; + const contentSize = Number.isFinite(input.contentSize) ? Math.max(0, input.contentSize) : 0; const minHandleSize = Number.isFinite(input.minHandleSize) ? Math.max(0, input.minHandleSize ?? DEFAULT_MIN_HANDLE_SIZE) : DEFAULT_MIN_HANDLE_SIZE; @@ -48,9 +44,7 @@ export function computeVerticalScrollbarGeometry( const maxHandleOffset = Math.max(0, viewportSize - handleSize); const clampedOffset = clamp(input.offset, 0, maxScrollOffset); const handleOffset = - maxScrollOffset > 0 - ? (clampedOffset / maxScrollOffset) * maxHandleOffset - : 0; + maxScrollOffset > 0 ? (clampedOffset / maxScrollOffset) * maxHandleOffset : 0; return { isVisible: true, @@ -68,9 +62,7 @@ export type ScrollOffsetFromDragDeltaInput = { maxHandleOffset: number; }; -export function computeScrollOffsetFromDragDelta( - input: ScrollOffsetFromDragDeltaInput -): number { +export function computeScrollOffsetFromDragDelta(input: ScrollOffsetFromDragDeltaInput): number { if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) { return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset)); } diff --git a/packages/app/src/components/web-desktop-scrollbar.tsx b/packages/app/src/components/web-desktop-scrollbar.tsx index f3394e709..1e54de0d6 100644 --- a/packages/app/src/components/web-desktop-scrollbar.tsx +++ b/packages/app/src/components/web-desktop-scrollbar.tsx @@ -29,10 +29,7 @@ const HANDLE_SCROLL_ACTIVE_MS = 110; function readClientY(event: any): number | null { const value = - event?.nativeEvent?.clientY ?? - event?.clientY ?? - event?.nativeEvent?.pageY ?? - event?.pageY; + event?.nativeEvent?.clientY ?? event?.clientY ?? event?.nativeEvent?.pageY ?? event?.pageY; return typeof value === "number" ? value : null; } @@ -74,19 +71,16 @@ export function useWebDesktopScrollbarMetrics() { contentSize: Math.max(0, contentSize.height), }); }, - [setMetricsIfChanged] + [setMetricsIfChanged], ); - const onLayout = useCallback( - (event: LayoutChangeEvent) => { - const viewportSize = Math.max(0, event.nativeEvent.layout.height); - setMetrics((previous) => { - const next = { ...previous, viewportSize }; - return areMetricsEqual(previous, next) ? previous : next; - }); - }, - [] - ); + const onLayout = useCallback((event: LayoutChangeEvent) => { + const viewportSize = Math.max(0, event.nativeEvent.layout.height); + setMetrics((previous) => { + const next = { ...previous, viewportSize }; + return areMetricsEqual(previous, next) ? previous : next; + }); + }, []); const onContentSizeChange = useCallback((_width: number, height: number) => { const contentSize = Math.max(0, height); @@ -156,7 +150,7 @@ export function WebDesktopScrollbarOverlay({ contentSize: metrics.contentSize, offset: normalizedOffset, }), - [metrics.contentSize, metrics.viewportSize, normalizedOffset] + [metrics.contentSize, metrics.viewportSize, normalizedOffset], ); useEffect(() => { @@ -243,7 +237,7 @@ export function WebDesktopScrollbarOverlay({ clearScrollActiveTimeout(); clearScrollVisibilityTimeout(); }, - [clearScrollActiveTimeout, clearScrollVisibilityTimeout] + [clearScrollActiveTimeout, clearScrollVisibilityTimeout], ); const applyDragDelta = useCallback( @@ -260,7 +254,7 @@ export function WebDesktopScrollbarOverlay({ : nextNormalizedOffset; onScrollToOffsetRef.current(nextOffset); }, - [inverted] + [inverted], ); const panResponder = useMemo(() => { @@ -304,7 +298,7 @@ export function WebDesktopScrollbarOverlay({ dragStartClientYRef.current = clientY; setIsDragging(true); }, - [isWeb] + [isWeb], ); const handleGrabHoverIn = useCallback(() => { @@ -357,16 +351,14 @@ export function WebDesktopScrollbarOverlay({ : 0; const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE; const isDark = theme.colors.surface0 === "#18181c"; - const handleColor = isDark - ? theme.colors.palette.zinc[500] - : theme.colors.palette.zinc[700]; + const handleColor = isDark ? theme.colors.palette.zinc[500] : theme.colors.palette.zinc[700]; const handleCursor = isDragging ? "grabbing" : "grab"; const handleTravelDurationMs = isDragging || isScrollActive ? 0 : HANDLE_TRAVEL_TRANSITION_DURATION_MS; const thumbRegionOffset = Math.max(0, geometry.handleOffset - HANDLE_GRAB_VERTICAL_PADDING); const thumbRegionHeight = Math.min( metrics.viewportSize - thumbRegionOffset, - geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2 + geometry.handleSize + HANDLE_GRAB_VERTICAL_PADDING * 2, ); const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2); @@ -416,8 +408,7 @@ export function WebDesktopScrollbarOverlay({ ({ transitionProperty: "opacity, width, background-color", transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`, - transitionTimingFunction: - "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", + transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out", } as any), ]} pointerEvents="none" diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 3936cd33a..1b6d65ec2 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -100,70 +100,78 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const appVersionText = formatVersionWithPrefix(appVersion); const [isDirectOpen, setIsDirectOpen] = useState(false); const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false); - const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); + const [pendingNameHost, setPendingNameHost] = useState<{ + serverId: string; + hostname: string | null; + } | null>(null); const [pendingRedirectServerId, setPendingRedirectServerId] = useState(null); const pendingNameHostname = useSessionStore( useCallback( (state) => { if (!pendingNameHost) return null; - return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; + return ( + state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? + pendingNameHost.hostname ?? + null + ); }, - [pendingNameHost] - ) + [pendingNameHost], + ), ); const finishOnboarding = useCallback( (serverId: string) => { router.replace(buildHostRootRoute(serverId) as any); }, - [router] + [router], ); - const actions: WelcomeAction[] = Platform.OS === "web" - ? [ - { - key: "direct-connection", - label: "Direct connection", - testID: "welcome-direct-connection", - primary: true, - icon: Link2, - onPress: () => setIsDirectOpen(true), - }, - { - key: "paste-pairing-link", - label: "Paste pairing link", - testID: "welcome-paste-pairing-link", - primary: false, - icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), - }, - ] - : [ - { - key: "scan-qr", - label: "Scan QR code", - testID: "welcome-scan-qr", - primary: true, - icon: QrCode, - onPress: () => router.push("/pair-scan?source=onboarding"), - }, - { - key: "direct-connection", - label: "Direct connection", - testID: "welcome-direct-connection", - primary: false, - icon: Link2, - onPress: () => setIsDirectOpen(true), - }, - { - key: "paste-pairing-link", - label: "Paste pairing link", - testID: "welcome-paste-pairing-link", - primary: false, - icon: ClipboardPaste, - onPress: () => setIsPasteLinkOpen(true), - }, - ]; + const actions: WelcomeAction[] = + Platform.OS === "web" + ? [ + { + key: "direct-connection", + label: "Direct connection", + testID: "welcome-direct-connection", + primary: true, + icon: Link2, + onPress: () => setIsDirectOpen(true), + }, + { + key: "paste-pairing-link", + label: "Paste pairing link", + testID: "welcome-paste-pairing-link", + primary: false, + icon: ClipboardPaste, + onPress: () => setIsPasteLinkOpen(true), + }, + ] + : [ + { + key: "scan-qr", + label: "Scan QR code", + testID: "welcome-scan-qr", + primary: true, + icon: QrCode, + onPress: () => router.push("/pair-scan?source=onboarding"), + }, + { + key: "direct-connection", + label: "Direct connection", + testID: "welcome-direct-connection", + primary: false, + icon: Link2, + onPress: () => setIsDirectOpen(true), + }, + { + key: "paste-pairing-link", + label: "Paste pairing link", + testID: "welcome-paste-pairing-link", + primary: false, + icon: ClipboardPaste, + onPress: () => setIsPasteLinkOpen(true), + }, + ]; return ( { const Icon = action.icon; return ( - - - - {action.label} - - + + + + {action.label} + + ); })} diff --git a/packages/app/src/constants/theme.ts b/packages/app/src/constants/theme.ts index f06facd2f..de2c1afbb 100644 --- a/packages/app/src/constants/theme.ts +++ b/packages/app/src/constants/theme.ts @@ -3,26 +3,26 @@ * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. */ -import { Platform } from 'react-native'; +import { Platform } from "react-native"; -const tintColorLight = '#0a7ea4'; -const tintColorDark = '#fff'; +const tintColorLight = "#0a7ea4"; +const tintColorDark = "#fff"; export const Colors = { light: { - text: '#11181C', - background: '#fff', + text: "#11181C", + background: "#fff", tint: tintColorLight, - icon: '#687076', - tabIconDefault: '#687076', + icon: "#687076", + tabIconDefault: "#687076", tabIconSelected: tintColorLight, }, dark: { - text: '#ECEDEE', - background: '#151718', + text: "#ECEDEE", + background: "#151718", tint: tintColorDark, - icon: '#9BA1A6', - tabIconDefault: '#9BA1A6', + icon: "#9BA1A6", + tabIconDefault: "#9BA1A6", tabIconSelected: tintColorDark, }, }; @@ -30,19 +30,19 @@ export const Colors = { export const Fonts = Platform.select({ ios: { /** iOS `UIFontDescriptorSystemDesignDefault` */ - sans: 'system-ui', + sans: "system-ui", /** iOS `UIFontDescriptorSystemDesignSerif` */ - serif: 'ui-serif', + serif: "ui-serif", /** iOS `UIFontDescriptorSystemDesignRounded` */ - rounded: 'ui-rounded', + rounded: "ui-rounded", /** iOS `UIFontDescriptorSystemDesignMonospaced` */ - mono: 'ui-monospace', + mono: "ui-monospace", }, default: { - sans: 'normal', - serif: 'serif', - rounded: 'normal', - mono: 'monospace', + sans: "normal", + serif: "serif", + rounded: "normal", + mono: "monospace", }, web: { sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif", diff --git a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx index 9bd103306..c9b724375 100644 --- a/packages/app/src/contexts/explorer-sidebar-animation-context.tsx +++ b/packages/app/src/contexts/explorer-sidebar-animation-context.tsx @@ -1,11 +1,6 @@ import { createContext, useContext, useEffect, useRef, type ReactNode } from "react"; import { useWindowDimensions } from "react-native"; -import { - useSharedValue, - withTiming, - Easing, - type SharedValue, -} from "react-native-reanimated"; +import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; @@ -22,12 +17,13 @@ interface ExplorerSidebarAnimationContextValue { closeGestureRef: React.MutableRefObject; } -const ExplorerSidebarAnimationContext = createContext(null); +const ExplorerSidebarAnimationContext = createContext( + null, +); export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) { const { width: windowWidth } = useWindowDimensions(); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); @@ -130,7 +126,9 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React export function useExplorerSidebarAnimation() { const context = useContext(ExplorerSidebarAnimationContext); if (!context) { - throw new Error("useExplorerSidebarAnimation must be used within ExplorerSidebarAnimationProvider"); + throw new Error( + "useExplorerSidebarAnimation must be used within ExplorerSidebarAnimationProvider", + ); } return context; } diff --git a/packages/app/src/contexts/horizontal-scroll-context.tsx b/packages/app/src/contexts/horizontal-scroll-context.tsx index 79673d5ce..8a4745461 100644 --- a/packages/app/src/contexts/horizontal-scroll-context.tsx +++ b/packages/app/src/contexts/horizontal-scroll-context.tsx @@ -31,7 +31,7 @@ export function HorizontalScrollProvider({ children }: { children: ReactNode }) scrollOffsetsRef.current.set(id, offset); updateIsAnyScrolled(); }, - [updateIsAnyScrolled] + [updateIsAnyScrolled], ); const unregisterScrollOffset = useCallback( @@ -39,7 +39,7 @@ export function HorizontalScrollProvider({ children }: { children: ReactNode }) scrollOffsetsRef.current.delete(id); updateIsAnyScrolled(); }, - [updateIsAnyScrolled] + [updateIsAnyScrolled], ); return ( diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 733dc6265..86e711ce0 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -5,10 +5,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useClientActivity } from "@/hooks/use-client-activity"; import { usePushTokenRegistration } from "@/hooks/use-push-token-registration"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; -import { - generateMessageId, - type StreamItem, -} from "@/types/stream"; +import { generateMessageId, type StreamItem } from "@/types/stream"; import { processTimelineResponse, processAgentStreamEvent, @@ -27,14 +24,8 @@ import { import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { DaemonClient } from "@server/client/daemon-client"; import { File } from "expo-file-system"; -import { - getHostRuntimeStore, - useHostRuntimeIsConnected, -} from "@/runtime/host-runtime"; -import { - useVoiceAudioEngineOptional, - useVoiceRuntimeOptional, -} from "@/contexts/voice-context"; +import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; import { useSessionStore, @@ -54,10 +45,7 @@ import { rejectInitDeferred, } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; -import { - derivePendingPermissionKey, - normalizeAgentSnapshot, -} from "@/utils/agent-snapshots"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; @@ -78,10 +66,7 @@ export type { const HISTORY_STALE_AFTER_MS = 60_000; const AUTHORITATIVE_REVALIDATION_DEBOUNCE_MS = 300; -type AudioOutputPayload = Extract< - SessionOutboundMessage, - { type: "audio_output" } ->["payload"]; +type AudioOutputPayload = Extract["payload"]; interface BufferedAudioChunk { chunkIndex: number; @@ -94,9 +79,7 @@ function decodeBase64Chunk(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } -function buildAudioPlaybackSource( - chunks: BufferedAudioChunk[] -): AudioPlaybackSource { +function buildAudioPlaybackSource(chunks: BufferedAudioChunk[]): AudioPlaybackSource { const decodedChunks = chunks.map((chunk) => decodeBase64Chunk(chunk.audio)); const totalSize = decodedChunks.reduce((sum, chunk) => sum + chunk.length, 0); const output = new Uint8Array(totalSize); @@ -111,7 +94,7 @@ function buildAudioPlaybackSource( format === "pcm" ? "audio/pcm;rate=24000;bits=16" : format === "mp3" - ? "audio/mpeg" + ? "audio/mpeg" : `audio/${format}`; const bytes = output.slice(); @@ -119,10 +102,7 @@ function buildAudioPlaybackSource( size: bytes.byteLength, type: mimeType, async arrayBuffer() { - return bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength - ); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); }, }; } @@ -139,7 +119,7 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => { const getLatestPermissionRequest = ( session: SessionState | undefined, - agentId: string + agentId: string, ): NotificationPermissionRequest | null => { if (!session) { return null; @@ -173,10 +153,7 @@ type FileDownloadTokenPayload = Extract< { type: "file_download_token_response" } >["payload"]; -type AgentUpdatePayload = Extract< - SessionOutboundMessage, - { type: "agent_update" } ->["payload"]; +type AgentUpdatePayload = Extract["payload"]; type WorkspaceUpdatePayload = Extract< SessionOutboundMessage, { type: "workspace_update" } @@ -197,14 +174,14 @@ function pendingKey(serverId: string, agentId: string): string { export function bufferPendingAgentUpdate( serverId: string, agentId: string, - update: AgentUpdatePayload + update: AgentUpdatePayload, ): void { pendingAgentUpdates.set(pendingKey(serverId, agentId), update); } export function flushPendingAgentUpdate( serverId: string, - agentId: string + agentId: string, ): AgentUpdatePayload | undefined { const key = pendingKey(serverId, agentId); const update = pendingAgentUpdates.get(key); @@ -212,10 +189,7 @@ export function flushPendingAgentUpdate( return update; } -export function deletePendingAgentUpdate( - serverId: string, - agentId: string -): void { +export function deletePendingAgentUpdate(serverId: string, agentId: string): void { pendingAgentUpdates.delete(pendingKey(serverId, agentId)); } @@ -238,11 +212,7 @@ interface SessionProviderClientProps extends SessionProviderSharedProps { export type SessionProviderProps = SessionProviderClientProps; -function SessionProviderWithClient({ - children, - serverId, - client, -}: SessionProviderClientProps) { +function SessionProviderWithClient({ children, serverId, client }: SessionProviderClientProps) { return ( {children} @@ -255,11 +225,7 @@ export function SessionProvider(props: SessionProviderProps) { return ; } -function SessionProviderInternal({ - children, - serverId, - client, -}: SessionProviderClientProps) { +function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) { const voiceRuntime = useVoiceRuntimeOptional(); const voiceAudioEngine = useVoiceAudioEngineOptional(); const queryClient = useQueryClient(); @@ -270,55 +236,29 @@ function SessionProviderInternal({ const clearSession = useSessionStore((state) => state.clearSession); const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); const setMessages = useSessionStore((state) => state.setMessages); - const setCurrentAssistantMessage = useSessionStore( - (state) => state.setCurrentAssistantMessage - ); - const setAgentStreamTail = useSessionStore( - (state) => state.setAgentStreamTail - ); - const setAgentStreamHead = useSessionStore( - (state) => state.setAgentStreamHead - ); - const setAgentStreamState = useSessionStore( - (state) => state.setAgentStreamState - ); - const clearAgentStreamHead = useSessionStore( - (state) => state.clearAgentStreamHead - ); - const setAgentTimelineCursor = useSessionStore( - (state) => state.setAgentTimelineCursor - ); - const setInitializingAgents = useSessionStore( - (state) => state.setInitializingAgents - ); - const bumpHistorySyncGeneration = useSessionStore( - (state) => state.bumpHistorySyncGeneration - ); + const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage); + const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); + const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); + const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState); + const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); + const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor); + const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); + const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); const markAgentHistorySynchronized = useSessionStore( - (state) => state.markAgentHistorySynchronized + (state) => state.markAgentHistorySynchronized, ); const setAgentAuthoritativeHistoryApplied = useSessionStore( - (state) => state.setAgentAuthoritativeHistoryApplied - ); - const setHasHydratedAgents = useSessionStore( - (state) => state.setHasHydratedAgents - ); - const setHasHydratedWorkspaces = useSessionStore( - (state) => state.setHasHydratedWorkspaces + (state) => state.setAgentAuthoritativeHistoryApplied, ); + const setHasHydratedAgents = useSessionStore((state) => state.setHasHydratedAgents); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const setAgents = useSessionStore((state) => state.setAgents); const setWorkspaces = useSessionStore((state) => state.setWorkspaces); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const removeWorkspace = useSessionStore((state) => state.removeWorkspace); - const setAgentLastActivity = useSessionStore( - (state) => state.setAgentLastActivity - ); - const flushAgentLastActivity = useSessionStore( - (state) => state.flushAgentLastActivity - ); - const setPendingPermissions = useSessionStore( - (state) => state.setPendingPermissions - ); + const setAgentLastActivity = useSessionStore((state) => state.setAgentLastActivity); + const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); + const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const clearDraftInput = useDraftStore((state) => state.clearDraftInput); const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); @@ -326,37 +266,22 @@ function SessionProviderInternal({ // Track focused agent for heartbeat const focusedAgentId = useSessionStore( - (state) => state.sessions[serverId]?.focusedAgentId ?? null - ); - const sessionAgents = useSessionStore( - (state) => state.sessions[serverId]?.agents + (state) => state.sessions[serverId]?.focusedAgentId ?? null, ); + const sessionAgents = useSessionStore((state) => state.sessions[serverId]?.agents); - const previousAgentStatusRef = useRef>( - new Map() - ); + const previousAgentStatusRef = useRef>(new Map()); const sendAgentMessageRef = useRef< - | (( - agentId: string, - message: string, - images?: AttachmentMetadata[] - ) => Promise) - | null + ((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise) | null >(null); - const sessionStateTimeoutRef = useRef | null>( - null - ); + const sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); - const revalidationTimerRef = useRef | null>( - null - ); + const revalidationTimerRef = useRef | null>(null); const revalidationInFlightRef = useRef | null>(null); const revalidationQueuedRef = useRef(false); const wasConnectedRef = useRef(isConnected); - const audioOutputBuffersRef = useRef>( - new Map() - ); + const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); useEffect(() => { @@ -420,7 +345,7 @@ function SessionProviderInternal({ setWorkspaces(serverId, workspaces); setHasHydratedWorkspaces(serverId, true); }, - [client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces] + [client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces], ); const applyAuthoritativeAgentSnapshot = useCallback( @@ -523,7 +448,7 @@ function SessionProviderInternal({ setAgents, setPendingPermissions, setQueuedMessages, - ] + ], ); const runAuthoritativeRevalidation = useCallback(async () => { @@ -596,7 +521,7 @@ function SessionProviderInternal({ } bumpHistorySyncGeneration(serverId); }, - [bumpHistorySyncGeneration, scheduleAuthoritativeRevalidation, serverId] + [bumpHistorySyncGeneration, scheduleAuthoritativeRevalidation, serverId], ); // Client activity tracking (heartbeat, push token registration) @@ -617,8 +542,7 @@ function SessionProviderInternal({ return; } const isActivelyVisible = getIsAppActivelyVisible(appState); - const isAwayFromAgent = - !isActivelyVisible || focusedAgentId !== params.agentId; + const isAwayFromAgent = !isActivelyVisible || focusedAgentId !== params.agentId; if (!isAwayFromAgent) { return; } @@ -633,12 +557,8 @@ function SessionProviderInternal({ const head = session?.agentStreamHead.get(params.agentId) ?? []; const tail = session?.agentStreamTail.get(params.agentId) ?? []; const assistantMessage = - findLatestAssistantMessageText(head) ?? - findLatestAssistantMessageText(tail); - const permissionRequest = getLatestPermissionRequest( - session, - params.agentId - ); + findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail); + const permissionRequest = getLatestPermissionRequest(session, params.agentId); const notification = params.notification ?? @@ -647,10 +567,7 @@ function SessionProviderInternal({ serverId, agentId: params.agentId, assistantMessage: params.reason === "finished" ? assistantMessage : null, - permissionRequest: - params.reason === "permission" - ? permissionRequest - : null, + permissionRequest: params.reason === "permission" ? permissionRequest : null, }); void sendOsNotification({ @@ -659,7 +576,7 @@ function SessionProviderInternal({ data: notification.data, }); }, - [serverId] + [serverId], ); // Initialize session in store @@ -676,7 +593,7 @@ function SessionProviderInternal({ return; } - return voiceRuntime.registerSession({ + return voiceRuntime.registerSession({ serverId, setVoiceMode: async (enabled, agentId) => { if (!client) { @@ -706,12 +623,7 @@ function SessionProviderInternal({ setIsPlayingAudio(serverId, isPlaying); }, }); - }, [ - client, - serverId, - setIsPlayingAudio, - voiceRuntime, - ]); + }, [client, serverId, setIsPlayingAudio, voiceRuntime]); useEffect(() => { voiceRuntime?.updateSessionConnection(serverId, isConnected); @@ -818,7 +730,7 @@ function SessionProviderInternal({ setAgents, setPendingPermissions, setAgentTimelineCursor, - ] + ], ); const requestCanonicalCatchUp = useCallback( @@ -831,14 +743,10 @@ function SessionProviderInternal({ projection: "canonical", }) .catch((error) => { - console.warn( - "[Session] failed to fetch canonical catch-up timeline", - agentId, - error - ); + console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error); }); }, - [client] + [client], ); const applyTimelineResponse = useCallback( @@ -846,7 +754,7 @@ function SessionProviderInternal({ payload: Extract< SessionOutboundMessage, { type: "fetch_agent_timeline_response" } - >["payload"] + >["payload"], ) => { const agentId = payload.agentId; const initKey = getInitKey(serverId, agentId); @@ -992,7 +900,7 @@ function SessionProviderInternal({ setAgentStreamTail, setAgentTimelineCursor, setInitializingAgents, - ] + ], ); useEffect(() => { @@ -1027,8 +935,7 @@ function SessionProviderInternal({ const initKey = getInitKey(serverId, agentId); const session = useSessionStore.getState().sessions[serverId]; const isSyncingHistory = - session?.initializingAgents.get(agentId) === true && - Boolean(getInitDeferred(initKey)); + session?.initializingAgents.get(agentId) === true && Boolean(getInitDeferred(initKey)); if (isSyncingHistory) { bufferPendingAgentUpdate(serverId, agentId, update); @@ -1160,13 +1067,10 @@ function SessionProviderInternal({ // on status changes, which is sufficient for sorting and display purposes. }); - const unsubAgentTimeline = client.on( - "fetch_agent_timeline_response", - (message) => { - if (message.type !== "fetch_agent_timeline_response") return; - applyTimelineResponse(message.payload); - } - ); + const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { + if (message.type !== "fetch_agent_timeline_response") return; + applyTimelineResponse(message.payload); + }); const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; @@ -1174,9 +1078,7 @@ function SessionProviderInternal({ removeWorkspace(serverId, message.payload.id); return; } - mergeWorkspaces(serverId, [ - normalizeWorkspaceDescriptor(message.payload.workspace), - ]); + mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]); }); const unsubStatus = client.on("status", (message) => { @@ -1187,53 +1089,42 @@ function SessionProviderInternal({ serverId: serverInfo.serverId, hostname: serverInfo.hostname, version: serverInfo.version, - ...(serverInfo.capabilities - ? { capabilities: serverInfo.capabilities } - : {}), + ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), }); return; } }); - const unsubPermissionRequest = client.on( - "agent_permission_request", - (message) => { - if (message.type !== "agent_permission_request") return; - const { agentId, request } = message.payload; + const unsubPermissionRequest = client.on("agent_permission_request", (message) => { + if (message.type !== "agent_permission_request") return; + const { agentId, request } = message.payload; - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - const key = derivePendingPermissionKey(agentId, request); - next.set(key, { key, agentId, request }); - return next; - }); - } - ); + setPendingPermissions(serverId, (prev) => { + const next = new Map(prev); + const key = derivePendingPermissionKey(agentId, request); + next.set(key, { key, agentId, request }); + return next; + }); + }); - const unsubPermissionResolved = client.on( - "agent_permission_resolved", - (message) => { - if (message.type !== "agent_permission_resolved") return; - const { requestId, agentId } = message.payload; + const unsubPermissionResolved = client.on("agent_permission_resolved", (message) => { + if (message.type !== "agent_permission_resolved") return; + const { requestId, agentId } = message.payload; - setPendingPermissions(serverId, (prev) => { - const next = new Map(prev); - const derivedKey = `${agentId}:${requestId}`; - if (!next.delete(derivedKey)) { - for (const [key, pending] of next.entries()) { - if ( - pending.agentId === agentId && - pending.request.id === requestId - ) { - next.delete(key); - break; - } + setPendingPermissions(serverId, (prev) => { + const next = new Map(prev); + const derivedKey = `${agentId}:${requestId}`; + if (!next.delete(derivedKey)) { + for (const [key, pending] of next.entries()) { + if (pending.agentId === agentId && pending.request.id === requestId) { + next.delete(key); + break; } } - return next; - }); - } - ); + } + return next; + }); + }); const unsubAudioOutput = client.on("audio_output", async (message) => { if (message.type !== "audio_output") return; @@ -1273,16 +1164,15 @@ function SessionProviderInternal({ bufferedChunks.sort((left, right) => left.chunkIndex - right.chunkIndex); const chunkIds = bufferedChunks.map((chunk) => chunk.id); const shouldPlay = - !payload.isVoiceMode || - (voiceRuntime?.shouldPlayVoiceAudio(serverId) ?? false); + !payload.isVoiceMode || (voiceRuntime?.shouldPlayVoiceAudio(serverId) ?? false); const audioBlob = buildAudioPlaybackSource(bufferedChunks); const confirmAudioPlayed = async () => { await Promise.all( chunkIds.map((chunkId) => client.audioPlayed(chunkId).catch((error) => { console.warn("[Session] Failed to confirm audio playback:", error); - }) - ) + }), + ), ); }; @@ -1352,17 +1242,13 @@ function SessionProviderInternal({ prev.map((msg) => msg.type === "tool_call" && msg.id === toolCallId ? { ...msg, result, status: "completed" as const } - : msg - ) + : msg, + ), ); return; } - if ( - data.type === "error" && - data.metadata && - "toolCallId" in data.metadata - ) { + if (data.type === "error" && data.metadata && "toolCallId" in data.metadata) { const { toolCallId, error } = data.metadata as { toolCallId: string; error: unknown; @@ -1372,8 +1258,8 @@ function SessionProviderInternal({ prev.map((msg) => msg.type === "tool_call" && msg.id === toolCallId ? { ...msg, error, status: "failed" as const } - : msg - ) + : msg, + ), ); } @@ -1422,10 +1308,7 @@ function SessionProviderInternal({ const unsubChunk = client.on("assistant_chunk", (message) => { if (message.type !== "assistant_chunk") return; - setCurrentAssistantMessage( - serverId, - (prev) => prev + message.payload.chunk - ); + setCurrentAssistantMessage(serverId, (prev) => prev + message.payload.chunk); }); const unsubTranscription = client.on("transcription_result", (message) => { @@ -1442,10 +1325,7 @@ function SessionProviderInternal({ const unsubVoiceInputState = client.on("voice_input_state", (message) => { if (message.type !== "voice_input_state") return; - voiceRuntime?.onServerSpeechStateChanged( - serverId, - message.payload.isSpeaking - ); + voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking); }); const unsubAgentDeleted = client.on("agent_deleted", (message) => { @@ -1589,11 +1469,7 @@ function SessionProviderInternal({ ]); const sendAgentMessage = useCallback( - async ( - agentId: string, - message: string, - images?: AttachmentMetadata[] - ) => { + async (agentId: string, message: string, images?: AttachmentMetadata[]) => { const messageId = generateMessageId(); const userMessage: StreamItem = { kind: "user_message", @@ -1605,7 +1481,9 @@ function SessionProviderInternal({ // Append to head if streaming (keeps the user message with the current // turn so late text_deltas still find the existing assistant_message). // Otherwise append to tail. - const currentHead = useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(agentId); + const currentHead = useSessionStore + .getState() + .sessions[serverId]?.agentStreamHead?.get(agentId); if (currentHead && currentHead.length > 0) { setAgentStreamHead(serverId, (prev) => { const head = prev.get(agentId) || []; @@ -1630,15 +1508,13 @@ function SessionProviderInternal({ void client .sendAgentMessage(agentId, message, { messageId, - ...(imagesData && imagesData.length > 0 - ? { images: imagesData } - : {}), + ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), }) .catch((error) => { console.error("[Session] Failed to send agent message:", error); }); }, - [encodeImages, serverId, client, setAgentStreamTail, setAgentStreamHead] + [encodeImages, serverId, client, setAgentStreamTail, setAgentStreamHead], ); // Keep the ref updated so the agent_update handler can call it @@ -1654,7 +1530,7 @@ function SessionProviderInternal({ console.error("[Session] Failed to cancel agent:", error); }); }, - [client] + [client], ); const deleteAgent = useCallback( @@ -1667,7 +1543,7 @@ function SessionProviderInternal({ console.error("[Session] Failed to delete agent:", error); }); }, - [client] + [client], ); const archiveAgent = useCallback( @@ -1680,7 +1556,7 @@ function SessionProviderInternal({ console.error("[Session] Failed to archive agent:", error); }); }, - [client] + [client], ); const restartServer = useCallback( @@ -1693,7 +1569,7 @@ function SessionProviderInternal({ console.error("[Session] Failed to restart server:", error); }); }, - [client] + [client], ); const createAgent = useCallback( @@ -1721,10 +1597,7 @@ function SessionProviderInternal({ try { imagesData = await encodeImages(images); } catch (error) { - console.error( - "[Session] Failed to prepare images for agent creation:", - error - ); + console.error("[Session] Failed to prepare images for agent creation:", error); } return client.createAgent({ config, @@ -1735,7 +1608,7 @@ function SessionProviderInternal({ ...(requestId ? { requestId } : {}), }); }, - [encodeImages, client] + [encodeImages, client], ); const setAgentMode = useCallback( @@ -1748,7 +1621,7 @@ function SessionProviderInternal({ console.error("[Session] Failed to set agent mode:", error); }); }, - [client] + [client], ); const setAgentModel = useCallback( @@ -1761,7 +1634,7 @@ function SessionProviderInternal({ console.error("[Session] Failed to set agent model:", error); }); }, - [client] + [client], ); const setAgentThinkingOption = useCallback( @@ -1770,13 +1643,11 @@ function SessionProviderInternal({ console.warn("[Session] setAgentThinkingOption skipped: daemon unavailable"); return; } - void client - .setAgentThinkingOption(agentId, thinkingOptionId) - .catch((error) => { - console.error("[Session] Failed to set agent thinking option:", error); - }); + void client.setAgentThinkingOption(agentId, thinkingOptionId).catch((error) => { + console.error("[Session] Failed to set agent thinking option:", error); + }); }, - [client] + [client], ); const respondToPermission = useCallback( @@ -1785,13 +1656,11 @@ function SessionProviderInternal({ console.warn("[Session] respondToPermission skipped: daemon unavailable"); return; } - void client - .respondToPermission(agentId, requestId, response) - .catch((error) => { - console.error("[Session] Failed to respond to permission:", error); - }); + void client.respondToPermission(agentId, requestId, response).catch((error) => { + console.error("[Session] Failed to respond to permission:", error); + }); }, - [client] + [client], ); // Cleanup on unmount diff --git a/packages/app/src/contexts/session-stream-lifecycle.test.ts b/packages/app/src/contexts/session-stream-lifecycle.test.ts index f00a2d4b0..99abd41ba 100644 --- a/packages/app/src/contexts/session-stream-lifecycle.test.ts +++ b/packages/app/src/contexts/session-stream-lifecycle.test.ts @@ -15,15 +15,9 @@ const turnFailedEvent: AgentStreamEventPayload = { describe("session stream lifecycle helpers", () => { it("derives optimistic terminal lifecycle only when current status is running", () => { - expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe( - "idle" - ); - expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe( - "error" - ); - expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe( - null - ); + expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle"); + expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error"); + expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null); expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null); }); }); diff --git a/packages/app/src/contexts/session-stream-lifecycle.ts b/packages/app/src/contexts/session-stream-lifecycle.ts index e8c9102bd..28145a636 100644 --- a/packages/app/src/contexts/session-stream-lifecycle.ts +++ b/packages/app/src/contexts/session-stream-lifecycle.ts @@ -3,7 +3,7 @@ import type { AgentStreamEventPayload } from "@server/shared/messages"; export function deriveOptimisticLifecycleStatus( currentStatus: AgentLifecycleStatus, - event: AgentStreamEventPayload + event: AgentStreamEventPayload, ): AgentLifecycleStatus | null { if (currentStatus !== "running") { return null; diff --git a/packages/app/src/contexts/session-stream-reducers.test.ts b/packages/app/src/contexts/session-stream-reducers.test.ts index 07e23ea31..580189da3 100644 --- a/packages/app/src/contexts/session-stream-reducers.test.ts +++ b/packages/app/src/contexts/session-stream-reducers.test.ts @@ -13,11 +13,7 @@ import { // Test helpers // --------------------------------------------------------------------------- -function makeTimelineEntry( - seq: number, - text: string, - type: string = "assistant_message" -) { +function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") { return { seqStart: seq, provider: "claude", @@ -28,7 +24,7 @@ function makeTimelineEntry( function makeTimelineEvent( text: string, - type: string = "assistant_message" + type: string = "assistant_message", ): AgentStreamEventPayload { return { type: "timeline", @@ -161,9 +157,7 @@ describe("processTimelineResponse", () => { endSeq: 3, }); expect(result.error).toBe(null); - expect( - result.sideEffects.some((e) => e.type === "flush_pending_updates") - ).toBe(true); + expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); }); it("sets cursor to null when reset=true but no cursors in payload", () => { @@ -193,10 +187,7 @@ describe("processTimelineResponse", () => { epoch: "epoch-1", startCursor: { seq: 1 }, endCursor: { seq: 5 }, - entries: [ - makeTimelineEntry(1, "first"), - makeTimelineEntry(5, "last"), - ], + entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")], }, }); @@ -232,10 +223,7 @@ describe("processTimelineResponse", () => { payload: { ...baseTimelineInput.payload, epoch: "epoch-1", - entries: [ - makeTimelineEntry(4, "next-1"), - makeTimelineEntry(5, "next-2"), - ], + entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")], }, }); @@ -262,9 +250,7 @@ describe("processTimelineResponse", () => { payload: { ...baseTimelineInput.payload, epoch: "epoch-1", - entries: [ - makeTimelineEntry(10, "far ahead"), - ], + entries: [makeTimelineEntry(10, "far ahead")], }, }); @@ -290,10 +276,7 @@ describe("processTimelineResponse", () => { payload: { ...baseTimelineInput.payload, epoch: "epoch-1", - entries: [ - makeTimelineEntry(5, "old"), - makeTimelineEntry(7, "also old"), - ], + entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")], }, }); @@ -315,9 +298,7 @@ describe("processTimelineResponse", () => { payload: { ...baseTimelineInput.payload, epoch: "epoch-2", - entries: [ - makeTimelineEntry(6, "different epoch"), - ], + entries: [makeTimelineEntry(6, "different epoch")], }, }); @@ -386,9 +367,7 @@ describe("processTimelineResponse", () => { }, }); - expect( - result.sideEffects.some((e) => e.type === "flush_pending_updates") - ).toBe(true); + expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true); }); it("initializes cursor when no existing cursor on first entries", () => { @@ -398,10 +377,7 @@ describe("processTimelineResponse", () => { payload: { ...baseTimelineInput.payload, epoch: "epoch-1", - entries: [ - makeTimelineEntry(1, "first"), - makeTimelineEntry(2, "second"), - ], + entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")], }, }); diff --git a/packages/app/src/contexts/session-stream-reducers.ts b/packages/app/src/contexts/session-stream-reducers.ts index 1158b8454..6623fba94 100644 --- a/packages/app/src/contexts/session-stream-reducers.ts +++ b/packages/app/src/contexts/session-stream-reducers.ts @@ -1,11 +1,7 @@ import type { AgentStreamEventPayload } from "@server/shared/messages"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { StreamItem } from "@/types/stream"; -import { - applyStreamEvent, - hydrateStreamState, - reduceStreamUpdate, -} from "@/types/stream"; +import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream"; import { classifySessionTimelineSeq, type SessionTimelineSeqDecision, @@ -84,7 +80,7 @@ export interface ProcessTimelineResponseOutput { } export function processTimelineResponse( - input: ProcessTimelineResponseInput + input: ProcessTimelineResponseInput, ): ProcessTimelineResponseOutput { const { payload, @@ -126,7 +122,7 @@ export function processTimelineResponse( })); const toHydratedEvents = ( - units: typeof timelineUnits + units: typeof timelineUnits, ): Array<{ event: AgentStreamEventPayload; timestamp: Date }> => units.map(({ event, timestamp }) => ({ event, timestamp })); @@ -186,17 +182,13 @@ export function processTimelineResponse( for (const unit of timelineUnits) { const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({ - cursor: cursor - ? { epoch: cursor.epoch, endSeq: cursor.endSeq } - : null, + cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null, epoch: payload.epoch, seq: unit.seq, }); if (decision === "gap") { - gapCursor = cursor - ? { epoch: cursor.epoch, endSeq: cursor.endSeq } - : null; + gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null; break; } if (decision === "drop_stale" || decision === "drop_epoch") { @@ -227,7 +219,7 @@ export function processTimelineResponse( reduceStreamUpdate(state, event, timestamp, { source: "canonical", }), - currentTail + currentTail, ); } @@ -262,12 +254,9 @@ export function processTimelineResponse( responseDirection: payload.direction, reset: payload.reset, }); - const clearInitializing = - shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred); + const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred); - const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit - ? "resolve" - : null; + const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null; return { tail: nextTail, @@ -319,18 +308,10 @@ export interface ProcessAgentStreamEventOutput { } export function processAgentStreamEvent( - input: ProcessAgentStreamEventInput + input: ProcessAgentStreamEventInput, ): ProcessAgentStreamEventOutput { - const { - event, - seq, - epoch, - currentTail, - currentHead, - currentCursor, - currentAgent, - timestamp, - } = input; + const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } = + input; let shouldApplyStreamEvent = true; let nextTimelineCursor: TimelineCursor | null = null; @@ -340,15 +321,9 @@ export function processAgentStreamEvent( // ------------------------------------------------------------------ // Timeline sequencing gate // ------------------------------------------------------------------ - if ( - event.type === "timeline" && - typeof seq === "number" && - typeof epoch === "string" - ) { + if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") { const decision = classifySessionTimelineSeq({ - cursor: currentCursor - ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } - : null, + cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null, epoch, seq, }); @@ -410,18 +385,12 @@ export function processAgentStreamEvent( event.type === "turn_canceled" || event.type === "turn_failed") ) { - const optimisticStatus = deriveOptimisticLifecycleStatus( - currentAgent.status, - event - ); + const optimisticStatus = deriveOptimisticLifecycleStatus(currentAgent.status, event); if (optimisticStatus) { - const nextUpdatedAtMs = Math.max( - currentAgent.updatedAt.getTime(), - timestamp.getTime() - ); + const nextUpdatedAtMs = Math.max(currentAgent.updatedAt.getTime(), timestamp.getTime()); const nextLastActivityAtMs = Math.max( currentAgent.lastActivityAt.getTime(), - timestamp.getTime() + timestamp.getTime(), ); agentPatch = { status: optimisticStatus, diff --git a/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts b/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts index ea2706a07..e233b8742 100644 --- a/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts +++ b/packages/app/src/contexts/session-timeline-bootstrap-policy.test.ts @@ -78,7 +78,7 @@ describe("shouldResolveTimelineInit", () => { initRequestDirection: "tail", responseDirection: "tail", reset: false, - }) + }), ).toBe(true); }); @@ -90,7 +90,7 @@ describe("shouldResolveTimelineInit", () => { initRequestDirection: "tail", responseDirection: "after", reset: false, - }) + }), ).toBe(false); }); @@ -102,7 +102,7 @@ describe("shouldResolveTimelineInit", () => { initRequestDirection: "after", responseDirection: "after", reset: false, - }) + }), ).toBe(true); }); }); diff --git a/packages/app/src/contexts/session-timeline-bootstrap-policy.ts b/packages/app/src/contexts/session-timeline-bootstrap-policy.ts index 32cc6dbe1..706ab5eee 100644 --- a/packages/app/src/contexts/session-timeline-bootstrap-policy.ts +++ b/packages/app/src/contexts/session-timeline-bootstrap-policy.ts @@ -62,8 +62,7 @@ export function deriveBootstrapTailTimelinePolicy({ return { replace: true, catchUpCursor: null }; } - const isBootstrapTailInit = - direction === "tail" && isInitializing && hasActiveInitDeferred; + const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred; if (!isBootstrapTailInit) { return { replace: false, catchUpCursor: null }; } diff --git a/packages/app/src/contexts/session-timeline-seq-gate.test.ts b/packages/app/src/contexts/session-timeline-seq-gate.test.ts index c66c79740..d18af910a 100644 --- a/packages/app/src/contexts/session-timeline-seq-gate.test.ts +++ b/packages/app/src/contexts/session-timeline-seq-gate.test.ts @@ -8,7 +8,7 @@ describe("classifySessionTimelineSeq", () => { cursor: { epoch: "epoch-1", endSeq: 4 }, epoch: "epoch-1", seq: 5, - }) + }), ).toBe("accept"); }); @@ -18,7 +18,7 @@ describe("classifySessionTimelineSeq", () => { cursor: { epoch: "epoch-1", endSeq: 8 }, epoch: "epoch-1", seq: 7, - }) + }), ).toBe("drop_stale"); }); @@ -28,7 +28,7 @@ describe("classifySessionTimelineSeq", () => { cursor: { epoch: "epoch-1", endSeq: 8 }, epoch: "epoch-1", seq: 8, - }) + }), ).toBe("drop_stale"); }); @@ -38,7 +38,7 @@ describe("classifySessionTimelineSeq", () => { cursor: { epoch: "epoch-1", endSeq: 4 }, epoch: "epoch-2", seq: 5, - }) + }), ).toBe("drop_epoch"); }); @@ -48,7 +48,7 @@ describe("classifySessionTimelineSeq", () => { cursor: null, epoch: "epoch-1", seq: 1, - }) + }), ).toBe("init"); }); @@ -58,7 +58,7 @@ describe("classifySessionTimelineSeq", () => { cursor: { epoch: "epoch-1", endSeq: 4 }, epoch: "epoch-1", seq: 9, - }) + }), ).toBe("gap"); }); }); diff --git a/packages/app/src/contexts/session-timeline-seq-gate.ts b/packages/app/src/contexts/session-timeline-seq-gate.ts index a2e56ec39..e7e35b567 100644 --- a/packages/app/src/contexts/session-timeline-seq-gate.ts +++ b/packages/app/src/contexts/session-timeline-seq-gate.ts @@ -6,12 +6,7 @@ export type SessionTimelineSeqCursor = | null | undefined; -export type SessionTimelineSeqDecision = - | "accept" - | "drop_stale" - | "drop_epoch" - | "gap" - | "init"; +export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init"; export function classifySessionTimelineSeq({ cursor, diff --git a/packages/app/src/contexts/sidebar-animation-context.tsx b/packages/app/src/contexts/sidebar-animation-context.tsx index eed2a16a0..c2a399937 100644 --- a/packages/app/src/contexts/sidebar-animation-context.tsx +++ b/packages/app/src/contexts/sidebar-animation-context.tsx @@ -1,11 +1,14 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ReactNode } from "react"; -import { useWindowDimensions } from "react-native"; import { - useSharedValue, - withTiming, - Easing, - type SharedValue, -} from "react-native-reanimated"; + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + type ReactNode, +} from "react"; +import { useWindowDimensions } from "react-native"; +import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated"; import { type GestureType } from "react-native-gesture-handler"; import { UnistylesRuntime } from "react-native-unistyles"; import { usePanelStore } from "@/stores/panel-store"; @@ -27,8 +30,7 @@ const SidebarAnimationContext = createContext state.mobileView); const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); @@ -112,13 +114,19 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode }) isGesturing, closeGestureRef, }), - [translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing, closeGestureRef] + [ + translateX, + backdropOpacity, + windowWidth, + animateToOpen, + animateToClose, + isGesturing, + closeGestureRef, + ], ); return ( - - {children} - + {children} ); } diff --git a/packages/app/src/contexts/toast-context.tsx b/packages/app/src/contexts/toast-context.tsx index fc4496207..1898380ed 100644 --- a/packages/app/src/contexts/toast-context.tsx +++ b/packages/app/src/contexts/toast-context.tsx @@ -1,9 +1,5 @@ import { createContext, useContext, type ReactNode } from "react"; -import { - ToastViewport, - useToastHost, - type ToastApi, -} from "@/components/toast-host"; +import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host"; const ToastContext = createContext(null); diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 6072e88b7..aa4dae037 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -59,7 +59,7 @@ export function useVoiceOptional(): VoiceContextValue | null { const snapshot = useSyncExternalStore( runtime ? runtime.subscribe : noopSubscribe, runtime ? runtime.getSnapshot : getEmptySnapshot, - runtime ? runtime.getSnapshot : getEmptySnapshot + runtime ? runtime.getSnapshot : getEmptySnapshot, ); if (!runtime) { @@ -88,7 +88,7 @@ export function useVoiceTelemetryOptional(): VoiceRuntimeTelemetrySnapshot | nul const snapshot = useSyncExternalStore( runtime ? runtime.subscribeTelemetry : noopSubscribe, runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry, - runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry + runtime ? runtime.getTelemetrySnapshot : getEmptyTelemetry, ); return runtime ? snapshot : null; @@ -153,9 +153,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) { return ( - - {children} - + {children} ); } diff --git a/packages/app/src/desktop/attachments/desktop-attachment-store.ts b/packages/app/src/desktop/attachments/desktop-attachment-store.ts index 87c047687..c6e7f9989 100644 --- a/packages/app/src/desktop/attachments/desktop-attachment-store.ts +++ b/packages/app/src/desktop/attachments/desktop-attachment-store.ts @@ -134,9 +134,7 @@ async function saveDesktopAttachmentFromBase64(input: { function assertDesktopAttachment(attachment: AttachmentMetadata): void { if (attachment.storageType !== "desktop-file") { - throw new Error( - `Unsupported desktop attachment storage type '${attachment.storageType}'.` - ); + throw new Error(`Unsupported desktop attachment storage type '${attachment.storageType}'.`); } } diff --git a/packages/app/src/desktop/attachments/desktop-file-commands.ts b/packages/app/src/desktop/attachments/desktop-file-commands.ts index fe5e8642f..b3ef197ec 100644 --- a/packages/app/src/desktop/attachments/desktop-file-commands.ts +++ b/packages/app/src/desktop/attachments/desktop-file-commands.ts @@ -10,14 +10,11 @@ export async function writeDesktopAttachmentBase64(input: { base64: string; extension?: string | null; }): Promise { - return await invokeDesktopCommand( - "write_attachment_base64", - { - attachmentId: input.attachmentId, - base64: input.base64, - extension: input.extension ?? null, - } - ); + return await invokeDesktopCommand("write_attachment_base64", { + attachmentId: input.attachmentId, + base64: input.base64, + extension: input.extension ?? null, + }); } export async function copyDesktopAttachmentFile(input: { @@ -32,9 +29,7 @@ export async function copyDesktopAttachmentFile(input: { }); } -export async function deleteDesktopAttachmentFile(input: { - path: string; -}): Promise { +export async function deleteDesktopAttachmentFile(input: { path: string }): Promise { return await invokeDesktopCommand("delete_attachment_file", { path: input.path, }); diff --git a/packages/app/src/desktop/attachments/desktop-preview-url.ts b/packages/app/src/desktop/attachments/desktop-preview-url.ts index c9a383d19..42f43e672 100644 --- a/packages/app/src/desktop/attachments/desktop-preview-url.ts +++ b/packages/app/src/desktop/attachments/desktop-preview-url.ts @@ -19,15 +19,10 @@ export async function readDesktopFileBase64(pathOrUri: string): Promise }); } -export async function resolveDesktopPreviewUrl( - attachment: AttachmentMetadata -): Promise { +export async function resolveDesktopPreviewUrl(attachment: AttachmentMetadata): Promise { const base64 = await readDesktopFileBase64(attachment.storageKey); - if ( - typeof URL !== "undefined" && - typeof URL.createObjectURL === "function" - ) { + if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") { const bytes = base64ToUint8Array(base64); const buffer = new ArrayBuffer(bytes.byteLength); new Uint8Array(buffer).set(bytes); @@ -40,9 +35,7 @@ export async function resolveDesktopPreviewUrl( return `data:${attachment.mimeType};base64,${base64}`; } -export async function releaseDesktopPreviewUrl(input: { - url: string; -}): Promise { +export async function releaseDesktopPreviewUrl(input: { url: string }): Promise { if (!activeObjectUrls.has(input.url)) { return; } diff --git a/packages/app/src/desktop/components/desktop-permission-row.tsx b/packages/app/src/desktop/components/desktop-permission-row.tsx index e176fad98..6622a5b41 100644 --- a/packages/app/src/desktop/components/desktop-permission-row.tsx +++ b/packages/app/src/desktop/components/desktop-permission-row.tsx @@ -65,7 +65,9 @@ export function DesktopPermissionRow({ {isRequesting ? "Requesting..." : "Request"} )} - {shouldShowDetail ? {status?.detail} : null} + {shouldShowDetail ? ( + {status?.detail} + ) : null} ); diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index d557fad82..86b385077 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -1,10 +1,10 @@ -import { useCallback, useEffect, useState } from 'react' -import { ActivityIndicator, Alert, Image, Text, View } from 'react-native' -import * as Clipboard from 'expo-clipboard' -import * as QRCode from 'qrcode' -import { useFocusEffect } from '@react-navigation/native' -import { StyleSheet, useUnistyles } from 'react-native-unistyles' -import { settingsStyles } from '@/styles/settings' +import { useCallback, useEffect, useState } from "react"; +import { ActivityIndicator, Alert, Image, Text, View } from "react-native"; +import * as Clipboard from "expo-clipboard"; +import * as QRCode from "qrcode"; +import { useFocusEffect } from "@react-navigation/native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { settingsStyles } from "@/styles/settings"; import { ArrowUpRight, Play, @@ -14,13 +14,13 @@ import { Copy, FileText, Smartphone, -} from 'lucide-react-native' -import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet' -import { Button } from '@/components/ui/button' -import { useAppSettings } from '@/hooks/use-settings' -import { confirmDialog } from '@/utils/confirm-dialog' -import { openExternalUrl } from '@/utils/open-external-url' -import { getLocalDaemonVersion, isVersionMismatch } from '@/desktop/updates/desktop-updates' +} from "lucide-react-native"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { useAppSettings } from "@/hooks/use-settings"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { getLocalDaemonVersion, isVersionMismatch } from "@/desktop/updates/desktop-updates"; import { getCliSymlinkInstructions, getDesktopDaemonLogs, @@ -34,291 +34,289 @@ import { type DesktopDaemonLogs, type DesktopDaemonStatus, type DesktopPairingOffer, -} from '@/desktop/daemon/desktop-daemon' +} from "@/desktop/daemon/desktop-daemon"; export interface LocalDaemonSectionProps { - appVersion: string | null - showLifecycleControls: boolean + appVersion: string | null; + showLifecycleControls: boolean; } -export function LocalDaemonSection({ - appVersion, - showLifecycleControls, -}: LocalDaemonSectionProps) { - const { theme } = useUnistyles() - const showSection = shouldUseDesktopDaemon() - const { settings, updateSettings } = useAppSettings() - const [daemonStatus, setDaemonStatus] = useState(null) - const [daemonVersion, setDaemonVersion] = useState(null) - const [statusError, setStatusError] = useState(null) - const [isRestartingDaemon, setIsRestartingDaemon] = useState(false) - const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false) - const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false) - const [statusMessage, setStatusMessage] = useState(null) - const [cliStatusMessage, setCliStatusMessage] = useState(null) - const [daemonLogs, setDaemonLogs] = useState(null) - const [isLogsModalOpen, setIsLogsModalOpen] = useState(false) - const [isPairingModalOpen, setIsPairingModalOpen] = useState(false) - const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false) - const [isLoadingPairing, setIsLoadingPairing] = useState(false) - const [pairingOffer, setPairingOffer] = useState(null) +export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalDaemonSectionProps) { + const { theme } = useUnistyles(); + const showSection = shouldUseDesktopDaemon(); + const { settings, updateSettings } = useAppSettings(); + const [daemonStatus, setDaemonStatus] = useState(null); + const [daemonVersion, setDaemonVersion] = useState(null); + const [statusError, setStatusError] = useState(null); + const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); + const [isUpdatingDaemonManagement, setIsUpdatingDaemonManagement] = useState(false); + const [isLoadingCliSymlinkInstructions, setIsLoadingCliSymlinkInstructions] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [cliStatusMessage, setCliStatusMessage] = useState(null); + const [daemonLogs, setDaemonLogs] = useState(null); + const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); + const [isPairingModalOpen, setIsPairingModalOpen] = useState(false); + const [isCliSymlinkModalOpen, setIsCliSymlinkModalOpen] = useState(false); + const [isLoadingPairing, setIsLoadingPairing] = useState(false); + const [pairingOffer, setPairingOffer] = useState(null); const [cliSymlinkInstructions, setCliSymlinkInstructions] = - useState(null) - const [pairingStatusMessage, setPairingStatusMessage] = useState(null) + useState(null); + const [pairingStatusMessage, setPairingStatusMessage] = useState(null); const loadDaemonData = useCallback(() => { if (!showSection) { - return Promise.resolve() + return Promise.resolve(); } return Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs(), getLocalDaemonVersion()]) .then(([status, logs, version]) => { - setDaemonStatus(status) - setDaemonLogs(logs) - setDaemonVersion(version.version) - setStatusError(null) + setDaemonStatus(status); + setDaemonLogs(logs); + setDaemonVersion(version.version); + setStatusError(null); }) .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - setStatusError(message) - }) - }, [showSection]) + const message = error instanceof Error ? error.message : String(error); + setStatusError(message); + }); + }, [showSection]); useFocusEffect( useCallback(() => { if (!showSection) { - return undefined + return undefined; } - void loadDaemonData() - return undefined - }, [loadDaemonData, showSection]) - ) + void loadDaemonData(); + return undefined; + }, [loadDaemonData, showSection]), + ); - const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion) + const daemonVersionMismatch = isVersionMismatch(appVersion, daemonVersion); const daemonStatusStateText = - statusError ?? (daemonStatus?.status === 'running' ? daemonStatus.status : 'not running') - const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : '—'}` - const isDaemonManagementPaused = !settings.manageBuiltInDaemon - const daemonActionLabel = daemonStatus?.status === 'running' ? 'Restart daemon' : 'Start daemon' + statusError ?? (daemonStatus?.status === "running" ? daemonStatus.status : "not running"); + const daemonStatusDetailText = `PID ${daemonStatus?.pid ? daemonStatus.pid : "—"}`; + const isDaemonManagementPaused = !settings.manageBuiltInDaemon; + const daemonActionLabel = daemonStatus?.status === "running" ? "Restart daemon" : "Start daemon"; const daemonActionMessage = - daemonStatus?.status === 'running' - ? 'Restarts the built-in daemon.' - : 'Starts the built-in daemon.' + daemonStatus?.status === "running" + ? "Restarts the built-in daemon." + : "Starts the built-in daemon."; const handleUpdateLocalDaemon = useCallback(() => { if (!showSection || isRestartingDaemon) { - return + return; } void confirmDialog({ title: daemonActionLabel, message: - daemonStatus?.status === 'running' - ? 'This will restart the built-in daemon. The app will reconnect automatically.' - : 'This will start the built-in daemon.', + daemonStatus?.status === "running" + ? "This will restart the built-in daemon. The app will reconnect automatically." + : "This will start the built-in daemon.", confirmLabel: daemonActionLabel, - cancelLabel: 'Cancel', + cancelLabel: "Cancel", }) .then((confirmed) => { if (!confirmed) { - return + return; } - setIsRestartingDaemon(true) - setStatusMessage(null) + setIsRestartingDaemon(true); + setStatusMessage(null); - const action = daemonStatus?.status === 'running' ? restartDesktopDaemon : startDesktopDaemon + const action = + daemonStatus?.status === "running" ? restartDesktopDaemon : startDesktopDaemon; void action() .then((status) => { - setDaemonStatus(status) + setDaemonStatus(status); setStatusMessage( - daemonStatus?.status === 'running' ? 'Daemon restarted.' : 'Daemon started.' - ) - return loadDaemonData() + daemonStatus?.status === "running" ? "Daemon restarted." : "Daemon started.", + ); + return loadDaemonData(); }) .catch((error) => { - console.error('[Settings] Failed to change desktop daemon state', error) - const message = error instanceof Error ? error.message : String(error) - setStatusMessage(`${daemonActionLabel} failed: ${message}`) + console.error("[Settings] Failed to change desktop daemon state", error); + const message = error instanceof Error ? error.message : String(error); + setStatusMessage(`${daemonActionLabel} failed: ${message}`); }) .finally(() => { - setIsRestartingDaemon(false) - }) + setIsRestartingDaemon(false); + }); }) .catch((error) => { - console.error('[Settings] Failed to open desktop daemon action confirmation', error) - Alert.alert('Error', 'Unable to open the daemon confirmation dialog.') - }) - }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]) + console.error("[Settings] Failed to open desktop daemon action confirmation", error); + Alert.alert("Error", "Unable to open the daemon confirmation dialog."); + }); + }, [daemonActionLabel, daemonStatus?.status, isRestartingDaemon, loadDaemonData, showSection]); const handleToggleDaemonManagement = useCallback(() => { if (isUpdatingDaemonManagement) { - return + return; } if (!settings.manageBuiltInDaemon) { - setIsUpdatingDaemonManagement(true) - setStatusMessage(null) + setIsUpdatingDaemonManagement(true); + setStatusMessage(null); void updateSettings({ manageBuiltInDaemon: true }) .then(() => { - setStatusMessage('Built-in daemon management resumed.') + setStatusMessage("Built-in daemon management resumed."); }) .catch((error) => { - console.error('[Settings] Failed to update built-in daemon management', error) - Alert.alert('Error', 'Unable to update built-in daemon management.') + console.error("[Settings] Failed to update built-in daemon management", error); + Alert.alert("Error", "Unable to update built-in daemon management."); }) .finally(() => { - setIsUpdatingDaemonManagement(false) - }) - return + setIsUpdatingDaemonManagement(false); + }); + return; } void confirmDialog({ - title: 'Pause built-in daemon', + title: "Pause built-in daemon", message: - 'This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.', - confirmLabel: 'Pause and stop', - cancelLabel: 'Cancel', + "This will stop the built-in daemon immediately. Running agents and terminals connected to the built-in daemon will be stopped.", + confirmLabel: "Pause and stop", + cancelLabel: "Cancel", destructive: true, }) .then((confirmed) => { if (!confirmed) { - return + return; } - setIsUpdatingDaemonManagement(true) - setStatusMessage(null) + setIsUpdatingDaemonManagement(true); + setStatusMessage(null); const stopPromise = - daemonStatus?.status === 'running' + daemonStatus?.status === "running" ? stopDesktopDaemon() - : Promise.resolve(daemonStatus ?? null) + : Promise.resolve(daemonStatus ?? null); void stopPromise .then(() => updateSettings({ manageBuiltInDaemon: false })) .then(() => loadDaemonData()) .then(() => { - setStatusMessage('Built-in daemon paused and stopped.') + setStatusMessage("Built-in daemon paused and stopped."); }) .catch((error) => { - console.error('[Settings] Failed to pause built-in daemon management', error) - Alert.alert('Error', 'Unable to pause built-in daemon management.') + console.error("[Settings] Failed to pause built-in daemon management", error); + Alert.alert("Error", "Unable to pause built-in daemon management."); }) .finally(() => { - setIsUpdatingDaemonManagement(false) - }) + setIsUpdatingDaemonManagement(false); + }); }) .catch((error) => { - console.error('[Settings] Failed to open built-in daemon pause confirmation', error) - Alert.alert('Error', 'Unable to open the daemon confirmation dialog.') - }) + console.error("[Settings] Failed to open built-in daemon pause confirmation", error); + Alert.alert("Error", "Unable to open the daemon confirmation dialog."); + }); }, [ daemonStatus, isUpdatingDaemonManagement, loadDaemonData, settings.manageBuiltInDaemon, updateSettings, - ]) + ]); const handleOpenCliSymlinkInstructions = useCallback(() => { if (!showSection || isLoadingCliSymlinkInstructions) { - return + return; } - setIsLoadingCliSymlinkInstructions(true) - setCliStatusMessage(null) + setIsLoadingCliSymlinkInstructions(true); + setCliStatusMessage(null); void getCliSymlinkInstructions() .then((instructions) => { - setCliSymlinkInstructions(instructions) - setIsCliSymlinkModalOpen(true) + setCliSymlinkInstructions(instructions); + setIsCliSymlinkModalOpen(true); }) .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`) + const message = error instanceof Error ? error.message : String(error); + setCliStatusMessage(`Unable to load CLI symlink instructions: ${message}`); }) .finally(() => { - setIsLoadingCliSymlinkInstructions(false) - }) - }, [isLoadingCliSymlinkInstructions, showSection]) + setIsLoadingCliSymlinkInstructions(false); + }); + }, [isLoadingCliSymlinkInstructions, showSection]); const handleCopyCliSymlinkCommands = useCallback(() => { if (!cliSymlinkInstructions?.commands) { - return + return; } void Clipboard.setStringAsync(cliSymlinkInstructions.commands) .then(() => { - Alert.alert('Copied', 'CLI symlink commands copied.') + Alert.alert("Copied", "CLI symlink commands copied."); }) .catch((error) => { - console.error('[Settings] Failed to copy CLI symlink commands', error) - Alert.alert('Error', 'Unable to copy CLI symlink commands.') - }) - }, [cliSymlinkInstructions?.commands]) + console.error("[Settings] Failed to copy CLI symlink commands", error); + Alert.alert("Error", "Unable to copy CLI symlink commands."); + }); + }, [cliSymlinkInstructions?.commands]); const handleCopyLogPath = useCallback(() => { - const logPath = daemonLogs?.logPath + const logPath = daemonLogs?.logPath; if (!logPath) { - return + return; } void Clipboard.setStringAsync(logPath) .then(() => { - Alert.alert('Copied', 'Log path copied.') + Alert.alert("Copied", "Log path copied."); }) .catch((error) => { - console.error('[Settings] Failed to copy log path', error) - Alert.alert('Error', 'Unable to copy log path.') - }) - }, [daemonLogs?.logPath]) + console.error("[Settings] Failed to copy log path", error); + Alert.alert("Error", "Unable to copy log path."); + }); + }, [daemonLogs?.logPath]); const handleOpenLogs = useCallback(() => { if (!daemonLogs) { - return + return; } - setIsLogsModalOpen(true) - }, [daemonLogs]) + setIsLogsModalOpen(true); + }, [daemonLogs]); const handleOpenPairingModal = useCallback(() => { if (isLoadingPairing) { - return + return; } - setIsPairingModalOpen(true) - setIsLoadingPairing(true) - setPairingStatusMessage(null) + setIsPairingModalOpen(true); + setIsLoadingPairing(true); + setPairingStatusMessage(null); void getDesktopDaemonPairing() .then((pairing) => { - setPairingOffer(pairing) + setPairingOffer(pairing); if (!pairing.relayEnabled || !pairing.url) { - setPairingStatusMessage('Relay pairing is not available.') + setPairingStatusMessage("Relay pairing is not available."); } }) .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - setPairingOffer(null) - setPairingStatusMessage(`Unable to load pairing offer: ${message}`) + const message = error instanceof Error ? error.message : String(error); + setPairingOffer(null); + setPairingStatusMessage(`Unable to load pairing offer: ${message}`); }) .finally(() => { - setIsLoadingPairing(false) - }) - }, [isLoadingPairing]) + setIsLoadingPairing(false); + }); + }, [isLoadingPairing]); const handleCopyPairingLink = useCallback(() => { if (!pairingOffer?.url) { - return + return; } void Clipboard.setStringAsync(pairingOffer.url) .then(() => { - Alert.alert('Copied', 'Pairing link copied.') + Alert.alert("Copied", "Pairing link copied."); }) .catch((error) => { - console.error('[Settings] Failed to copy pairing link', error) - Alert.alert('Error', 'Unable to copy pairing link.') - }) - }, [pairingOffer?.url]) + console.error("[Settings] Failed to copy pairing link", error); + Alert.alert("Error", "Unable to copy pairing link."); + }); + }, [pairingOffer?.url]); if (!showSection) { - return null + return null; } return ( @@ -355,8 +353,8 @@ export function LocalDaemonSection({ Daemon management {isDaemonManagementPaused - ? 'Paused. The built-in daemon stays stopped until you start it again.' - : 'Enabled. Paseo can manage the built-in daemon from the desktop app.'} + ? "Paused. The built-in daemon stays stopped until you start it again." + : "Enabled. Paseo can manage the built-in daemon from the desktop app."} @@ -395,9 +393,9 @@ export function LocalDaemonSection({ disabled={isRestartingDaemon} > {isRestartingDaemon - ? daemonStatus?.status === 'running' - ? 'Restarting...' - : 'Starting...' + ? daemonStatus?.status === "running" + ? "Restarting..." + : "Starting..." : daemonActionLabel} @@ -416,13 +414,13 @@ export function LocalDaemonSection({ onPress={handleOpenCliSymlinkInstructions} disabled={isLoadingCliSymlinkInstructions} > - {isLoadingCliSymlinkInstructions ? 'Loading...' : 'Show instructions'} + {isLoadingCliSymlinkInstructions ? "Loading..." : "Show instructions"} Log file - {daemonLogs?.logPath ?? 'Log path unavailable.'} + {daemonLogs?.logPath ?? "Log path unavailable."} {daemonLogs?.logPath ? ( @@ -485,7 +483,7 @@ export function LocalDaemonSection({ {cliSymlinkInstructions.detail} ) : null} - {cliSymlinkInstructions?.commands ?? ''} + {cliSymlinkInstructions?.commands ?? ""} - ) + ); } const styles = StyleSheet.create((theme) => ({ sectionHeader: { - alignItems: 'center', - flexDirection: 'row', - justifyContent: 'space-between', + alignItems: "center", + flexDirection: "row", + justifyContent: "space-between", marginBottom: theme.spacing[3], marginLeft: theme.spacing[1], }, sectionLink: { - alignItems: 'center', - flexDirection: 'row', + alignItems: "center", + flexDirection: "row", gap: theme.spacing[1], }, sectionLinkText: { @@ -651,9 +649,9 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, }, row: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", paddingVertical: theme.spacing[4], paddingHorizontal: theme.spacing[4], }, @@ -666,13 +664,13 @@ const styles = StyleSheet.create((theme) => ({ marginRight: theme.spacing[3], }, actionGroup: { - flexDirection: 'row', + flexDirection: "row", gap: theme.spacing[2], - flexWrap: 'wrap', - justifyContent: 'flex-end', + flexWrap: "wrap", + justifyContent: "flex-end", }, statusValueGroup: { - alignItems: 'flex-end', + alignItems: "flex-end", gap: 2, }, rowTitle: { @@ -703,7 +701,7 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: theme.colors.palette.amber[500], - backgroundColor: 'rgba(245, 158, 11, 0.12)', + backgroundColor: "rgba(245, 158, 11, 0.12)", paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], }, @@ -716,17 +714,17 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[2], }, pairingState: { - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", gap: theme.spacing[3], paddingVertical: theme.spacing[6], }, qrCard: { - alignItems: 'center', - justifyContent: 'center', - width: '100%', + alignItems: "center", + justifyContent: "center", + width: "100%", aspectRatio: 1, - alignSelf: 'stretch', + alignSelf: "stretch", padding: theme.spacing[3], borderRadius: theme.borderRadius.lg, borderWidth: 1, @@ -734,8 +732,8 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface0, }, qrImage: { - width: '100%', - height: '100%', + width: "100%", + height: "100%", }, linkSection: { gap: theme.spacing[2], @@ -752,13 +750,13 @@ const styles = StyleSheet.create((theme) => ({ logOutput: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: 18, }, codeBlock: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: 18, borderWidth: 1, borderColor: theme.colors.border, @@ -767,8 +765,8 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[3], }, modalActions: { - flexDirection: 'row', - justifyContent: 'flex-end', + flexDirection: "row", + justifyContent: "flex-end", gap: theme.spacing[2], }, -})) +})); diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts index 80153d3f8..b7ed732ff 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.test.ts @@ -1,23 +1,31 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const desktopDaemonMock = vi.hoisted(() => { - let eventHandler: ((payload: { - sessionId: string; - kind: "open" | "message" | "close" | "error"; - text?: string | null; - binaryBase64?: string | null; - code?: number | null; - reason?: string | null; - error?: string | null; - }) => void) | null = null; + let eventHandler: + | ((payload: { + sessionId: string; + kind: "open" | "message" | "close" | "error"; + text?: string | null; + binaryBase64?: string | null; + code?: number | null; + reason?: string | null; + error?: string | null; + }) => void) + | null = null; const openLocalTransportSession = vi.fn<(...args: unknown[]) => Promise>(); - const listenToLocalTransportEvents = vi.fn(async (handler: typeof eventHandler extends ((...args: infer A) => any) | null ? (...args: A) => void : never) => { - eventHandler = handler; - return () => { - eventHandler = null; - }; - }); + const listenToLocalTransportEvents = vi.fn( + async ( + handler: typeof eventHandler extends ((...args: infer A) => any) | null + ? (...args: A) => void + : never, + ) => { + eventHandler = handler; + return () => { + eventHandler = null; + }; + }, + ); const sendLocalTransportMessage = vi.fn(async () => undefined); const closeLocalTransportSession = vi.fn(async () => undefined); @@ -61,7 +69,7 @@ describe("desktop-daemon-transport", () => { () => new Promise((resolve) => { resolveSession = resolve; - }) + }), ); const mod = await import("./desktop-daemon-transport"); @@ -97,13 +105,13 @@ describe("desktop-daemon-transport", () => { () => new Promise((resolve) => { resolveSession = resolve; - }) + }), ); desktopDaemonMock.listenToLocalTransportEvents.mockImplementation( () => new Promise<() => void>((resolve) => { resolveListen = resolve; - }) + }), ); const mod = await import("./desktop-daemon-transport"); diff --git a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts index 890fac2d3..ded938b64 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon-transport.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon-transport.ts @@ -142,15 +142,15 @@ export function createDesktopLocalDaemonTransportFactory(): DaemonTransportFacto } if (typeof data === "string") { void sendLocalTransportMessage({ sessionId, text: data }).catch((error) => - emitError(error) + emitError(error), ); return; } const binaryBase64 = encodeBinaryToBase64( - data instanceof ArrayBuffer ? data : new Uint8Array(data) + data instanceof ArrayBuffer ? data : new Uint8Array(data), ); void sendLocalTransportMessage({ sessionId, binaryBase64 }).catch((error) => - emitError(error) + emitError(error), ); }, close: () => { diff --git a/packages/app/src/desktop/daemon/desktop-daemon.ts b/packages/app/src/desktop/daemon/desktop-daemon.ts index 6ec353408..a4186ac04 100644 --- a/packages/app/src/desktop/daemon/desktop-daemon.ts +++ b/packages/app/src/desktop/daemon/desktop-daemon.ts @@ -1,214 +1,214 @@ -import { getDesktopHost, isDesktop } from '@/desktop/host' -import { invokeDesktopCommand } from '@/desktop/electron/invoke' +import { getDesktopHost, isDesktop } from "@/desktop/host"; +import { invokeDesktopCommand } from "@/desktop/electron/invoke"; -export type DesktopDaemonState = 'starting' | 'running' | 'stopped' | 'errored' +export type DesktopDaemonState = "starting" | "running" | "stopped" | "errored"; export type DesktopDaemonStatus = { - serverId: string - status: DesktopDaemonState - listen: string - hostname: string | null - pid: number | null - home: string - error: string | null -} + serverId: string; + status: DesktopDaemonState; + listen: string; + hostname: string | null; + pid: number | null; + home: string; + error: string | null; +}; export type DesktopDaemonLogs = { - logPath: string - contents: string -} + logPath: string; + contents: string; +}; export type DesktopPairingOffer = { - relayEnabled: boolean - url: string | null - qr: string | null -} + relayEnabled: boolean; + url: string | null; + qr: string | null; +}; export type CliSymlinkInstructions = { - title: string - detail: string - commands: string -} + title: string; + detail: string; + commands: string; +}; export type LocalTransportTarget = { - transportType: 'socket' | 'pipe' - transportPath: string -} + transportType: "socket" | "pipe"; + transportPath: string; +}; type LocalTransportEventPayload = { - sessionId: string - kind: 'open' | 'message' | 'close' | 'error' - text?: string | null - binaryBase64?: string | null - code?: number | null - reason?: string | null - error?: string | null -} + sessionId: string; + kind: "open" | "message" | "close" | "error"; + text?: string | null; + binaryBase64?: string | null; + code?: number | null; + reason?: string | null; + error?: string | null; +}; function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null + return typeof value === "object" && value !== null; } function toStringOrNull(value: unknown): string | null { - return typeof value === 'string' && value.trim().length > 0 ? value : null + return typeof value === "string" && value.trim().length > 0 ? value : null; } function toNumberOrNull(value: unknown): number | null { - return typeof value === 'number' && Number.isFinite(value) ? value : null + return typeof value === "number" && Number.isFinite(value) ? value : null; } function parseDesktopDaemonState(value: unknown): DesktopDaemonState { - const normalized = toStringOrNull(value)?.toLowerCase() + const normalized = toStringOrNull(value)?.toLowerCase(); switch (normalized) { - case 'starting': - return 'starting' - case 'running': - return 'running' - case 'errored': - case 'error': - return 'errored' - case 'stopped': - case 'stopping': - case 'unknown': + case "starting": + return "starting"; + case "running": + return "running"; + case "errored": + case "error": + return "errored"; + case "stopped": + case "stopping": + case "unknown": default: - return 'stopped' + return "stopped"; } } function parseDesktopDaemonStatus(raw: unknown): DesktopDaemonStatus { if (!isRecord(raw)) { - throw new Error('Unexpected desktop daemon status response.') + throw new Error("Unexpected desktop daemon status response."); } return { - serverId: toStringOrNull(raw.serverId) ?? '', + serverId: toStringOrNull(raw.serverId) ?? "", status: parseDesktopDaemonState(raw.status), - listen: toStringOrNull(raw.listen) ?? '', + listen: toStringOrNull(raw.listen) ?? "", hostname: toStringOrNull(raw.hostname), pid: toNumberOrNull(raw.pid), - home: toStringOrNull(raw.home) ?? '', + home: toStringOrNull(raw.home) ?? "", error: toStringOrNull(raw.error), - } + }; } function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs { if (!isRecord(raw)) { - throw new Error('Unexpected desktop daemon logs response.') + throw new Error("Unexpected desktop daemon logs response."); } return { - logPath: toStringOrNull(raw.logPath) ?? '', - contents: typeof raw.contents === 'string' ? raw.contents : '', - } + logPath: toStringOrNull(raw.logPath) ?? "", + contents: typeof raw.contents === "string" ? raw.contents : "", + }; } function parseDesktopPairingOffer(raw: unknown): DesktopPairingOffer { if (!isRecord(raw)) { - throw new Error('Unexpected desktop daemon pairing response.') + throw new Error("Unexpected desktop daemon pairing response."); } return { relayEnabled: raw.relayEnabled === true, url: toStringOrNull(raw.url), qr: toStringOrNull(raw.qr), - } + }; } function parseCliSymlinkInstructionsInternal(raw: unknown): CliSymlinkInstructions | null { if (!isRecord(raw)) { - return null + return null; } return { - title: toStringOrNull(raw.title) ?? '', - detail: toStringOrNull(raw.detail) ?? '', - commands: toStringOrNull(raw.commands) ?? '', - } + title: toStringOrNull(raw.title) ?? "", + detail: toStringOrNull(raw.detail) ?? "", + commands: toStringOrNull(raw.commands) ?? "", + }; } export function shouldUseDesktopDaemon(): boolean { - return isDesktop() + return isDesktop(); } export async function getDesktopDaemonStatus(): Promise { - return parseDesktopDaemonStatus(await invokeDesktopCommand('desktop_daemon_status')) + return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status")); } export async function startDesktopDaemon(): Promise { - return parseDesktopDaemonStatus(await invokeDesktopCommand('start_desktop_daemon')) + return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon")); } export async function stopDesktopDaemon(): Promise { - return parseDesktopDaemonStatus(await invokeDesktopCommand('stop_desktop_daemon')) + return parseDesktopDaemonStatus(await invokeDesktopCommand("stop_desktop_daemon")); } export async function restartDesktopDaemon(): Promise { - return parseDesktopDaemonStatus(await invokeDesktopCommand('restart_desktop_daemon')) + return parseDesktopDaemonStatus(await invokeDesktopCommand("restart_desktop_daemon")); } export async function getDesktopDaemonLogs(): Promise { - return parseDesktopDaemonLogs(await invokeDesktopCommand('desktop_daemon_logs')) + return parseDesktopDaemonLogs(await invokeDesktopCommand("desktop_daemon_logs")); } export async function getDesktopDaemonPairing(): Promise { - return parseDesktopPairingOffer(await invokeDesktopCommand('desktop_daemon_pairing')) + return parseDesktopPairingOffer(await invokeDesktopCommand("desktop_daemon_pairing")); } export function parseCliSymlinkInstructions(raw: unknown): CliSymlinkInstructions { - const instructions = parseCliSymlinkInstructionsInternal(raw) + const instructions = parseCliSymlinkInstructionsInternal(raw); if (!instructions) { - throw new Error('Unexpected CLI symlink instructions response.') + throw new Error("Unexpected CLI symlink instructions response."); } - return instructions + return instructions; } export async function getCliSymlinkInstructions(): Promise { - return parseCliSymlinkInstructions(await invokeDesktopCommand('cli_symlink_instructions')) + return parseCliSymlinkInstructions(await invokeDesktopCommand("cli_symlink_instructions")); } -export type LocalTransportEventUnlisten = () => void -export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void +export type LocalTransportEventUnlisten = () => void; +export type LocalTransportEventHandler = (payload: LocalTransportEventPayload) => void; export async function listenToLocalTransportEvents( - handler: LocalTransportEventHandler + handler: LocalTransportEventHandler, ): Promise { - const listen = getDesktopHost()?.events?.on - if (typeof listen !== 'function') { - throw new Error('Desktop events API is unavailable.') + const listen = getDesktopHost()?.events?.on; + if (typeof listen !== "function") { + throw new Error("Desktop events API is unavailable."); } - const unlisten = await listen('local-daemon-transport-event', (payload: unknown) => { + const unlisten = await listen("local-daemon-transport-event", (payload: unknown) => { if (!isRecord(payload)) { - return + return; } handler({ - sessionId: toStringOrNull(payload.sessionId) ?? '', - kind: (toStringOrNull(payload.kind) ?? 'error') as LocalTransportEventPayload['kind'], + sessionId: toStringOrNull(payload.sessionId) ?? "", + kind: (toStringOrNull(payload.kind) ?? "error") as LocalTransportEventPayload["kind"], text: toStringOrNull(payload.text), binaryBase64: toStringOrNull(payload.binaryBase64), code: toNumberOrNull(payload.code), reason: toStringOrNull(payload.reason), error: toStringOrNull(payload.error), - }) - }) - return typeof unlisten === 'function' ? unlisten : () => {} + }); + }); + return typeof unlisten === "function" ? unlisten : () => {}; } export async function openLocalTransportSession(target: LocalTransportTarget): Promise { - const raw = await invokeDesktopCommand('open_local_daemon_transport', target) - if (typeof raw !== 'string' || raw.trim().length === 0) { - throw new Error('Unexpected local transport session response.') + const raw = await invokeDesktopCommand("open_local_daemon_transport", target); + if (typeof raw !== "string" || raw.trim().length === 0) { + throw new Error("Unexpected local transport session response."); } - return raw + return raw; } export async function sendLocalTransportMessage(input: { - sessionId: string - text?: string - binaryBase64?: string + sessionId: string; + text?: string; + binaryBase64?: string; }): Promise { - await invokeDesktopCommand('send_local_daemon_transport_message', { + await invokeDesktopCommand("send_local_daemon_transport_message", { sessionId: input.sessionId, ...(input.text ? { text: input.text } : {}), ...(input.binaryBase64 ? { binaryBase64: input.binaryBase64 } : {}), - }) + }); } export async function closeLocalTransportSession(sessionId: string): Promise { - await invokeDesktopCommand('close_local_daemon_transport', { sessionId }) + await invokeDesktopCommand("close_local_daemon_transport", { sessionId }); } diff --git a/packages/app/src/desktop/electron/events.ts b/packages/app/src/desktop/electron/events.ts index 3ab5743d1..409f13a06 100644 --- a/packages/app/src/desktop/electron/events.ts +++ b/packages/app/src/desktop/electron/events.ts @@ -8,7 +8,7 @@ type EventEnvelope = { export async function listenToDesktopEvent( event: string, - handler: (payload: TPayload) => void + handler: (payload: TPayload) => void, ): Promise { const listen = getDesktopHost()?.events?.on; if (typeof listen !== "function") { diff --git a/packages/app/src/desktop/electron/invoke.ts b/packages/app/src/desktop/electron/invoke.ts index 7b4d88b22..3fe77b78d 100644 --- a/packages/app/src/desktop/electron/invoke.ts +++ b/packages/app/src/desktop/electron/invoke.ts @@ -2,7 +2,7 @@ import { getDesktopHost } from "@/desktop/host"; export async function invokeDesktopCommand( command: string, - args?: Record + args?: Record, ): Promise { const invoke = getDesktopHost()?.invoke; if (typeof invoke !== "function") { diff --git a/packages/app/src/desktop/host.ts b/packages/app/src/desktop/host.ts index 5479cf6fc..7d1019207 100644 --- a/packages/app/src/desktop/host.ts +++ b/packages/app/src/desktop/host.ts @@ -23,15 +23,13 @@ export interface DesktopDialogOpenOptions { export interface DesktopDialogBridge { ask?: (message: string, options?: DesktopDialogAskOptions) => Promise; - open?: ( - options?: DesktopDialogOpenOptions - ) => Promise; + open?: (options?: DesktopDialogOpenOptions) => Promise; } export interface DesktopNotificationBridge { isSupported?: () => Promise; sendNotification?: ( - payload: string | { title: string; body?: string; data?: Record } + payload: string | { title: string; body?: string; data?: Record }, ) => Promise; } @@ -47,11 +45,11 @@ export interface DesktopWindowBridge { toggleMaximize?: () => Promise; isFullscreen?: () => Promise; onResized?: ( - handler: (event: TEvent) => void + handler: (event: TEvent) => void, ) => Promise<() => void> | (() => void); setBadgeCount?: (count?: number) => Promise; onDragDropEvent?: ( - handler: (event: TEvent) => void + handler: (event: TEvent) => void, ) => Promise<() => void> | (() => void); } @@ -60,10 +58,7 @@ export interface DesktopWindowModuleBridge { } export interface DesktopEventsBridge { - on?: ( - event: string, - handler: (payload: unknown) => void - ) => Promise<() => void> | (() => void); + on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void); } export interface DesktopInvokeBridge { diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts index 532b2b347..a62162835 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts @@ -1,198 +1,192 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from "vitest"; -type MockPlatform = 'web' | 'ios' | 'android' +type MockPlatform = "web" | "ios" | "android"; type GlobalSnapshot = { - Notification: unknown - navigatorDescriptor?: PropertyDescriptor - paseoDesktop: unknown -} + Notification: unknown; + navigatorDescriptor?: PropertyDescriptor; + paseoDesktop: unknown; +}; const originalGlobals: GlobalSnapshot = { Notification: (globalThis as { Notification?: unknown }).Notification, - navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, 'navigator'), + navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"), paseoDesktop: - typeof window === 'undefined' - ? undefined - : (window as { paseoDesktop?: unknown }).paseoDesktop, -} + typeof window === "undefined" ? undefined : (window as { paseoDesktop?: unknown }).paseoDesktop, +}; function setNavigator(value: unknown): void { - Object.defineProperty(globalThis, 'navigator', { + Object.defineProperty(globalThis, "navigator", { configurable: true, writable: true, value, - }) + }); } function restoreGlobals(): void { - ;(globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification + (globalThis as { Notification?: unknown }).Notification = originalGlobals.Notification; if (originalGlobals.navigatorDescriptor) { - Object.defineProperty(globalThis, 'navigator', originalGlobals.navigatorDescriptor) + Object.defineProperty(globalThis, "navigator", originalGlobals.navigatorDescriptor); } else { - delete (globalThis as { navigator?: unknown }).navigator + delete (globalThis as { navigator?: unknown }).navigator; } - if (typeof window !== 'undefined') { - ;(window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop + if (typeof window !== "undefined") { + (window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop; } } async function loadModuleForPlatform(platform: MockPlatform) { - vi.resetModules() - vi.doMock('react-native', () => ({ Platform: { OS: platform } })) - return import('./desktop-permissions') + vi.resetModules(); + vi.doMock("react-native", () => ({ Platform: { OS: platform } })); + return import("./desktop-permissions"); } -describe('desktop-permissions', () => { +describe("desktop-permissions", () => { afterEach(() => { - vi.doUnmock('react-native') - vi.restoreAllMocks() - vi.resetModules() - restoreGlobals() - }) + vi.doUnmock("react-native"); + vi.restoreAllMocks(); + vi.resetModules(); + restoreGlobals(); + }); - it('shows section only in desktop web runtime', async () => { - const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform('web') + it("shows section only in desktop web runtime", async () => { + const { shouldShowDesktopPermissionSection } = await loadModuleForPlatform("web"); - expect(shouldShowDesktopPermissionSection()).toBe(false) + expect(shouldShowDesktopPermissionSection()).toBe(false); - ;(window as { paseoDesktop?: unknown }).paseoDesktop = {} - expect(shouldShowDesktopPermissionSection()).toBe(true) - }) + (window as { paseoDesktop?: unknown }).paseoDesktop = {}; + expect(shouldShowDesktopPermissionSection()).toBe(true); + }); - it('reads notification and microphone status', async () => { + it("reads notification and microphone status", async () => { class MockNotification { - static permission = 'default' + static permission = "default"; } - ;(globalThis as { Notification?: unknown }).Notification = MockNotification + (globalThis as { Notification?: unknown }).Notification = MockNotification; setNavigator({ permissions: { - query: vi.fn(async () => ({ state: 'granted' })), + query: vi.fn(async () => ({ state: "granted" })), }, mediaDevices: { getUserMedia: vi.fn(), }, - }) + }); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') - const snapshot = await getDesktopPermissionSnapshot() + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); + const snapshot = await getDesktopPermissionSnapshot(); - expect(snapshot.notifications.state).toBe('prompt') - expect(snapshot.microphone.state).toBe('granted') - expect(snapshot.checkedAt).toBeTypeOf('number') - }) + expect(snapshot.notifications.state).toBe("prompt"); + expect(snapshot.microphone.state).toBe("granted"); + expect(snapshot.checkedAt).toBeTypeOf("number"); + }); - it('queries microphone permission with correct Permissions instance binding', async () => { + it("queries microphone permission with correct Permissions instance binding", async () => { const permissions = { query(this: unknown, _descriptor: { name: string }) { if (this !== permissions) { - throw new TypeError( - 'Can only call Permissions.query on instances of Permissions' - ) + throw new TypeError("Can only call Permissions.query on instances of Permissions"); } - return Promise.resolve({ state: 'granted' as const }) + return Promise.resolve({ state: "granted" as const }); }, - } + }; setNavigator({ permissions, mediaDevices: { getUserMedia: vi.fn(), }, - }) + }); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') - const snapshot = await getDesktopPermissionSnapshot() + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); + const snapshot = await getDesktopPermissionSnapshot(); - expect(snapshot.microphone.state).toBe('granted') - }) + expect(snapshot.microphone.state).toBe("granted"); + }); - it('returns a fallback message when runtime blocks Permissions.query', async () => { + it("returns a fallback message when runtime blocks Permissions.query", async () => { setNavigator({ permissions: { query: vi.fn(async () => { - throw new TypeError( - 'Can only call Permissions.query on instances of Permissions' - ) + throw new TypeError("Can only call Permissions.query on instances of Permissions"); }), }, mediaDevices: { getUserMedia: vi.fn(), }, - }) + }); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') - const snapshot = await getDesktopPermissionSnapshot() + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); + const snapshot = await getDesktopPermissionSnapshot(); - expect(snapshot.microphone.state).toBe('unknown') + expect(snapshot.microphone.state).toBe("unknown"); expect(snapshot.microphone.detail).toContain( - 'Microphone status API is unavailable in this runtime.' - ) - }) + "Microphone status API is unavailable in this runtime.", + ); + }); - it('requests notification permission via the browser Notification API', async () => { + it("requests notification permission via the browser Notification API", async () => { class MockNotification { - static permission = 'default' - static requestPermission = vi.fn(async () => 'granted') + static permission = "default"; + static requestPermission = vi.fn(async () => "granted"); } - ;(globalThis as { Notification?: unknown }).Notification = MockNotification + (globalThis as { Notification?: unknown }).Notification = MockNotification; - const { requestDesktopPermission } = await loadModuleForPlatform('web') - const result = await requestDesktopPermission({ kind: 'notifications' }) + const { requestDesktopPermission } = await loadModuleForPlatform("web"); + const result = await requestDesktopPermission({ kind: "notifications" }); - expect(result.state).toBe('granted') - expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1) - }) + expect(result.state).toBe("granted"); + expect(MockNotification.requestPermission).toHaveBeenCalledTimes(1); + }); - it('reads browser Notification permission when available', async () => { + it("reads browser Notification permission when available", async () => { class MockNotification { - static permission = 'denied' + static permission = "denied"; } - ;(globalThis as { Notification?: unknown }).Notification = MockNotification - setNavigator({}) + (globalThis as { Notification?: unknown }).Notification = MockNotification; + setNavigator({}); - const { getDesktopPermissionSnapshot } = await loadModuleForPlatform('web') - const snapshot = await getDesktopPermissionSnapshot() + const { getDesktopPermissionSnapshot } = await loadModuleForPlatform("web"); + const snapshot = await getDesktopPermissionSnapshot(); - expect(snapshot.notifications.state).toBe('denied') - }) + expect(snapshot.notifications.state).toBe("denied"); + }); - it('requests microphone permission and stops acquired tracks', async () => { - const stop = vi.fn() + it("requests microphone permission and stops acquired tracks", async () => { + const stop = vi.fn(); const getUserMedia = vi.fn(async () => ({ getTracks: () => [{ stop }], - })) + })); setNavigator({ permissions: { - query: vi.fn(async () => ({ state: 'granted' })), + query: vi.fn(async () => ({ state: "granted" })), }, mediaDevices: { getUserMedia, }, - }) + }); - const { requestDesktopPermission } = await loadModuleForPlatform('web') - const result = await requestDesktopPermission({ kind: 'microphone' }) + const { requestDesktopPermission } = await loadModuleForPlatform("web"); + const result = await requestDesktopPermission({ kind: "microphone" }); - expect(result.state).toBe('granted') - expect(getUserMedia).toHaveBeenCalledWith({ audio: true }) - expect(stop).toHaveBeenCalledTimes(1) - }) + expect(result.state).toBe("granted"); + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); + expect(stop).toHaveBeenCalledTimes(1); + }); - it('maps microphone request denial to denied status', async () => { + it("maps microphone request denial to denied status", async () => { setNavigator({ mediaDevices: { getUserMedia: vi.fn(async () => { - throw { name: 'NotAllowedError', message: 'denied' } + throw { name: "NotAllowedError", message: "denied" }; }), }, - }) + }); - const { requestDesktopPermission } = await loadModuleForPlatform('web') - const result = await requestDesktopPermission({ kind: 'microphone' }) + const { requestDesktopPermission } = await loadModuleForPlatform("web"); + const result = await requestDesktopPermission({ kind: "microphone" }); - expect(result.state).toBe('denied') - }) -}) + expect(result.state).toBe("denied"); + }); +}); diff --git a/packages/app/src/desktop/permissions/desktop-permissions.ts b/packages/app/src/desktop/permissions/desktop-permissions.ts index 2c4926ab9..6dd3d5974 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.ts @@ -1,332 +1,332 @@ -import { Platform } from 'react-native' -import { getDesktopHost } from '@/desktop/host' +import { Platform } from "react-native"; +import { getDesktopHost } from "@/desktop/host"; -export type DesktopPermissionKind = 'notifications' | 'microphone' +export type DesktopPermissionKind = "notifications" | "microphone"; export type DesktopPermissionState = - | 'granted' - | 'denied' - | 'prompt' - | 'not-granted' - | 'unavailable' - | 'unknown' + | "granted" + | "denied" + | "prompt" + | "not-granted" + | "unavailable" + | "unknown"; export interface DesktopPermissionStatus { - state: DesktopPermissionState - detail: string + state: DesktopPermissionState; + detail: string; } export interface DesktopPermissionSnapshot { - checkedAt: number - notifications: DesktopPermissionStatus - microphone: DesktopPermissionStatus + checkedAt: number; + notifications: DesktopPermissionStatus; + microphone: DesktopPermissionStatus; } type NotificationConstructorLike = { - permission?: string - requestPermission?: () => Promise -} + permission?: string; + requestPermission?: () => Promise; +}; type MediaStreamTrackLike = { - stop?: () => void -} + stop?: () => void; +}; type MediaStreamLike = { - getTracks?: () => MediaStreamTrackLike[] -} + getTracks?: () => MediaStreamTrackLike[]; +}; type NavigatorLike = { mediaDevices?: { - getUserMedia?: (constraints: { audio: boolean }) => Promise - } + getUserMedia?: (constraints: { audio: boolean }) => Promise; + }; permissions?: { - query?: (descriptor: { name: string }) => Promise<{ state?: string }> - } -} + query?: (descriptor: { name: string }) => Promise<{ state?: string }>; + }; +}; export function shouldShowDesktopPermissionSection(): boolean { - return Platform.OS === 'web' && getDesktopHost() !== null + return Platform.OS === "web" && getDesktopHost() !== null; } function status(input: DesktopPermissionStatus): DesktopPermissionStatus { - return input + return input; } function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null + return typeof value === "object" && value !== null; } function getErrorMessage(error: unknown): string { - if (error instanceof Error && typeof error.message === 'string') { - return error.message + if (error instanceof Error && typeof error.message === "string") { + return error.message; } - return String(error) + return String(error); } function getErrorName(error: unknown): string | null { if (!isObject(error)) { - return null + return null; } - const name = error.name - return typeof name === 'string' && name.length > 0 ? name : null + const name = error.name; + return typeof name === "string" && name.length > 0 ? name : null; } function isPermissionsQueryRuntimeUnsupported(error: unknown): boolean { - const message = getErrorMessage(error) + const message = getErrorMessage(error); if ( - message.includes('Can only call Permissions.query on instances of Permissions') || - message.includes('Illegal invocation') + message.includes("Can only call Permissions.query on instances of Permissions") || + message.includes("Illegal invocation") ) { - return true + return true; } - return false + return false; } function getWebNotificationConstructor(): NotificationConstructorLike | null { - if (Platform.OS !== 'web') { - return null + if (Platform.OS !== "web") { + return null; } - const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification + const NotificationConstructor = (globalThis as { Notification?: unknown }).Notification; if ( NotificationConstructor == null || - (typeof NotificationConstructor !== 'function' && typeof NotificationConstructor !== 'object') + (typeof NotificationConstructor !== "function" && typeof NotificationConstructor !== "object") ) { - return null + return null; } - return NotificationConstructor as NotificationConstructorLike + return NotificationConstructor as NotificationConstructorLike; } function getNavigatorLike(): NavigatorLike | null { - if (Platform.OS !== 'web') { - return null + if (Platform.OS !== "web") { + return null; } - const webNavigator = (globalThis as { navigator?: unknown }).navigator + const webNavigator = (globalThis as { navigator?: unknown }).navigator; if (!isObject(webNavigator)) { - return null + return null; } - return webNavigator as NavigatorLike + return webNavigator as NavigatorLike; } function mapNotificationPermissionString(permission: string): DesktopPermissionStatus { - if (permission === 'granted') { + if (permission === "granted") { return status({ - state: 'granted', - detail: 'Notifications are allowed by the OS.', - }) + state: "granted", + detail: "Notifications are allowed by the OS.", + }); } - if (permission === 'denied') { + if (permission === "denied") { return status({ - state: 'denied', - detail: 'Notifications are denied in system settings.', - }) + state: "denied", + detail: "Notifications are denied in system settings.", + }); } - if (permission === 'default') { + if (permission === "default") { return status({ - state: 'prompt', - detail: 'Notifications have not been granted yet.', - }) + state: "prompt", + detail: "Notifications have not been granted yet.", + }); } return status({ - state: 'unknown', + state: "unknown", detail: `Unexpected notification permission state: ${permission}`, - }) + }); } async function getNotificationPermissionStatus(): Promise { - if (Platform.OS !== 'web') { + if (Platform.OS !== "web") { return status({ - state: 'unavailable', - detail: 'Desktop notification status is only available on web runtime.', - }) + state: "unavailable", + detail: "Desktop notification status is only available on web runtime.", + }); } - const desktopHost = getDesktopHost() - if (desktopHost && typeof desktopHost.notification?.isSupported === 'function') { + const desktopHost = getDesktopHost(); + if (desktopHost && typeof desktopHost.notification?.isSupported === "function") { try { - const supported = await desktopHost.notification.isSupported() + const supported = await desktopHost.notification.isSupported(); return status({ - state: supported ? 'granted' : 'unavailable', + state: supported ? "granted" : "unavailable", detail: supported - ? 'Desktop notifications are supported.' - : 'Desktop notifications are not supported on this platform.', - }) + ? "Desktop notifications are supported." + : "Desktop notifications are not supported on this platform.", + }); } catch { // Fall through to web API check } } - const NotificationConstructor = getWebNotificationConstructor() - if (NotificationConstructor && typeof NotificationConstructor.permission === 'string') { - return mapNotificationPermissionString(NotificationConstructor.permission) + const NotificationConstructor = getWebNotificationConstructor(); + if (NotificationConstructor && typeof NotificationConstructor.permission === "string") { + return mapNotificationPermissionString(NotificationConstructor.permission); } return status({ - state: 'unavailable', - detail: 'Web Notification API is unavailable in this environment.', - }) + state: "unavailable", + detail: "Web Notification API is unavailable in this environment.", + }); } async function getMicrophonePermissionStatus(): Promise { - if (Platform.OS !== 'web') { + if (Platform.OS !== "web") { return status({ - state: 'unavailable', - detail: 'Desktop microphone status is only available on web runtime.', - }) + state: "unavailable", + detail: "Desktop microphone status is only available on web runtime.", + }); } - const webNavigator = getNavigatorLike() + const webNavigator = getNavigatorLike(); if (!webNavigator) { return status({ - state: 'unavailable', - detail: 'Navigator is unavailable in this environment.', - }) + state: "unavailable", + detail: "Navigator is unavailable in this environment.", + }); } - const permissionsApi = webNavigator.permissions - if (permissionsApi && typeof permissionsApi.query === 'function') { + const permissionsApi = webNavigator.permissions; + if (permissionsApi && typeof permissionsApi.query === "function") { try { - const result = await permissionsApi.query({ name: 'microphone' }) - if (result?.state === 'granted') { + const result = await permissionsApi.query({ name: "microphone" }); + if (result?.state === "granted") { return status({ - state: 'granted', - detail: 'Microphone access is granted.', - }) + state: "granted", + detail: "Microphone access is granted.", + }); } - if (result?.state === 'denied') { + if (result?.state === "denied") { return status({ - state: 'denied', - detail: 'Microphone access is denied in system settings.', - }) + state: "denied", + detail: "Microphone access is denied in system settings.", + }); } - if (result?.state === 'prompt') { + if (result?.state === "prompt") { return status({ - state: 'prompt', - detail: 'Microphone permission has not been granted yet.', - }) + state: "prompt", + detail: "Microphone permission has not been granted yet.", + }); } return status({ - state: 'unknown', - detail: `Unexpected microphone permission state: ${result?.state ?? 'unknown'}`, - }) + state: "unknown", + detail: `Unexpected microphone permission state: ${result?.state ?? "unknown"}`, + }); } catch (error) { if (isPermissionsQueryRuntimeUnsupported(error)) { return status({ - state: 'unknown', + state: "unknown", detail: - 'Microphone status API is unavailable in this runtime. Use Request to check access.', - }) + "Microphone status API is unavailable in this runtime. Use Request to check access.", + }); } return status({ - state: 'unknown', + state: "unknown", detail: `Failed to query microphone status: ${getErrorMessage(error)}`, - }) + }); } } - if (typeof webNavigator.mediaDevices?.getUserMedia !== 'function') { + if (typeof webNavigator.mediaDevices?.getUserMedia !== "function") { return status({ - state: 'unavailable', - detail: 'Microphone capture is unavailable in this environment.', - }) + state: "unavailable", + detail: "Microphone capture is unavailable in this environment.", + }); } return status({ - state: 'unknown', - detail: 'Permission status API is unavailable. Use Request to check access.', - }) + state: "unknown", + detail: "Permission status API is unavailable. Use Request to check access.", + }); } async function requestNotificationPermissionStatus(): Promise { - if (Platform.OS !== 'web') { + if (Platform.OS !== "web") { return status({ - state: 'unavailable', - detail: 'Desktop notification requests are only available on web runtime.', - }) + state: "unavailable", + detail: "Desktop notification requests are only available on web runtime.", + }); } - const NotificationConstructor = getWebNotificationConstructor() - if (NotificationConstructor && typeof NotificationConstructor.requestPermission === 'function') { + const NotificationConstructor = getWebNotificationConstructor(); + if (NotificationConstructor && typeof NotificationConstructor.requestPermission === "function") { try { - const permission = await NotificationConstructor.requestPermission() - return mapNotificationPermissionString(permission) + const permission = await NotificationConstructor.requestPermission(); + return mapNotificationPermissionString(permission); } catch (error) { return status({ - state: 'unknown', + state: "unknown", detail: `Failed to request notification permission: ${getErrorMessage(error)}`, - }) + }); } } return status({ - state: 'unavailable', - detail: 'Web Notification API requestPermission() is unavailable.', - }) + state: "unavailable", + detail: "Web Notification API requestPermission() is unavailable.", + }); } async function requestMicrophonePermissionStatus(): Promise { - if (Platform.OS !== 'web') { + if (Platform.OS !== "web") { return status({ - state: 'unavailable', - detail: 'Desktop microphone requests are only available on web runtime.', - }) + state: "unavailable", + detail: "Desktop microphone requests are only available on web runtime.", + }); } - const webNavigator = getNavigatorLike() - if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== 'function') { + const webNavigator = getNavigatorLike(); + if (!webNavigator || typeof webNavigator.mediaDevices?.getUserMedia !== "function") { return status({ - state: 'unavailable', - detail: 'Microphone capture API is unavailable in this environment.', - }) + state: "unavailable", + detail: "Microphone capture API is unavailable in this environment.", + }); } try { - const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }) - const tracks = stream && typeof stream.getTracks === 'function' ? stream.getTracks() : [] + const stream = await webNavigator.mediaDevices.getUserMedia({ audio: true }); + const tracks = stream && typeof stream.getTracks === "function" ? stream.getTracks() : []; tracks.forEach((track) => { - if (typeof track.stop === 'function') { - track.stop() + if (typeof track.stop === "function") { + track.stop(); } - }) - return await getMicrophonePermissionStatus() + }); + return await getMicrophonePermissionStatus(); } catch (error) { - const errorName = getErrorName(error) - if (errorName === 'NotAllowedError' || errorName === 'PermissionDeniedError') { + const errorName = getErrorName(error); + if (errorName === "NotAllowedError" || errorName === "PermissionDeniedError") { return status({ - state: 'denied', - detail: 'Microphone permission was denied by the user or system.', - }) + state: "denied", + detail: "Microphone permission was denied by the user or system.", + }); } - if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') { + if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") { return status({ - state: 'unavailable', - detail: 'No microphone device was found.', - }) + state: "unavailable", + detail: "No microphone device was found.", + }); } return status({ - state: 'unknown', + state: "unknown", detail: `Failed to request microphone permission: ${getErrorMessage(error)}`, - }) + }); } } export async function requestDesktopPermission(input: { - kind: DesktopPermissionKind + kind: DesktopPermissionKind; }): Promise { - if (input.kind === 'notifications') { - return await requestNotificationPermissionStatus() + if (input.kind === "notifications") { + return await requestNotificationPermissionStatus(); } - return await requestMicrophonePermissionStatus() + return await requestMicrophonePermissionStatus(); } export async function getDesktopPermissionSnapshot(): Promise { const [notifications, microphone] = await Promise.all([ getNotificationPermissionStatus(), getMicrophonePermissionStatus(), - ]) + ]); return { checkedAt: Date.now(), notifications, microphone, - } + }; } diff --git a/packages/app/src/desktop/permissions/use-desktop-permissions.ts b/packages/app/src/desktop/permissions/use-desktop-permissions.ts index 782f005d6..e393511e3 100644 --- a/packages/app/src/desktop/permissions/use-desktop-permissions.ts +++ b/packages/app/src/desktop/permissions/use-desktop-permissions.ts @@ -35,7 +35,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn { const [snapshot, setSnapshot] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [requestingPermission, setRequestingPermission] = useState( - null + null, ); const [isSendingTestNotification, setIsSendingTestNotification] = useState(false); @@ -80,12 +80,11 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn { } setSnapshot((previous) => { - const base: DesktopPermissionSnapshot = - previous ?? { - checkedAt: Date.now(), - notifications: EMPTY_NOTIFICATION_STATUS, - microphone: EMPTY_MICROPHONE_STATUS, - }; + const base: DesktopPermissionSnapshot = previous ?? { + checkedAt: Date.now(), + notifications: EMPTY_NOTIFICATION_STATUS, + microphone: EMPTY_MICROPHONE_STATUS, + }; if (kind === "notifications") { return { @@ -110,7 +109,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn { await refreshPermissions(); } }, - [isDesktop, refreshPermissions] + [isDesktop, refreshPermissions], ); const sendTestNotification = useCallback(async () => { diff --git a/packages/app/src/desktop/pick-directory.ts b/packages/app/src/desktop/pick-directory.ts index 1f52fc400..08c27a5d9 100644 --- a/packages/app/src/desktop/pick-directory.ts +++ b/packages/app/src/desktop/pick-directory.ts @@ -1,23 +1,23 @@ -import { getDesktopHost } from '@/desktop/host' +import { getDesktopHost } from "@/desktop/host"; export async function pickDirectory(): Promise { - const open = getDesktopHost()?.dialog?.open - if (typeof open !== 'function') { - throw new Error('Desktop dialog open() is unavailable in this environment.') + const open = getDesktopHost()?.dialog?.open; + if (typeof open !== "function") { + throw new Error("Desktop dialog open() is unavailable in this environment."); } const selection = await open({ directory: true, multiple: false, - }) + }); if (selection === null) { - return null + return null; } - if (typeof selection === 'string') { - return selection + if (typeof selection === "string") { + return selection; } - throw new Error('Unexpected directory picker response.') + throw new Error("Unexpected directory picker response."); } diff --git a/packages/app/src/desktop/updates/desktop-updates.test.ts b/packages/app/src/desktop/updates/desktop-updates.test.ts index de69ac57e..dc7eb3783 100644 --- a/packages/app/src/desktop/updates/desktop-updates.test.ts +++ b/packages/app/src/desktop/updates/desktop-updates.test.ts @@ -1,95 +1,95 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from "vitest"; -async function loadModuleForPlatform(platform: 'web' | 'ios' | 'android') { - vi.resetModules() - vi.doMock('react-native', () => ({ Platform: { OS: platform } })) - return import('./desktop-updates') +async function loadModuleForPlatform(platform: "web" | "ios" | "android") { + vi.resetModules(); + vi.doMock("react-native", () => ({ Platform: { OS: platform } })); + return import("./desktop-updates"); } -describe('desktop-updates helpers', () => { +describe("desktop-updates helpers", () => { afterEach(() => { - vi.doUnmock('react-native') - vi.restoreAllMocks() - vi.resetModules() - }) + vi.doUnmock("react-native"); + vi.restoreAllMocks(); + vi.resetModules(); + }); - it('normalizes versions for app-daemon comparisons', async () => { - const { normalizeVersionForComparison } = await loadModuleForPlatform('web') + it("normalizes versions for app-daemon comparisons", async () => { + const { normalizeVersionForComparison } = await loadModuleForPlatform("web"); - expect(normalizeVersionForComparison(' v0.1.15 ')).toBe('0.1.15') - expect(normalizeVersionForComparison('0.1.15')).toBe('0.1.15') - expect(normalizeVersionForComparison(null)).toBeNull() - }) + expect(normalizeVersionForComparison(" v0.1.15 ")).toBe("0.1.15"); + expect(normalizeVersionForComparison("0.1.15")).toBe("0.1.15"); + expect(normalizeVersionForComparison(null)).toBeNull(); + }); - it('detects version mismatch after normalization', async () => { - const { isVersionMismatch } = await loadModuleForPlatform('web') + it("detects version mismatch after normalization", async () => { + const { isVersionMismatch } = await loadModuleForPlatform("web"); - expect(isVersionMismatch('v0.1.15', '0.1.15')).toBe(false) - expect(isVersionMismatch('0.1.15', '0.1.16')).toBe(true) - expect(isVersionMismatch('0.1.15', null)).toBe(false) - }) + expect(isVersionMismatch("v0.1.15", "0.1.15")).toBe(false); + expect(isVersionMismatch("0.1.15", "0.1.16")).toBe(true); + expect(isVersionMismatch("0.1.15", null)).toBe(false); + }); - it('formats display versions with v prefix and unavailable fallback', async () => { - const { formatVersionWithPrefix } = await loadModuleForPlatform('web') + it("formats display versions with v prefix and unavailable fallback", async () => { + const { formatVersionWithPrefix } = await loadModuleForPlatform("web"); - expect(formatVersionWithPrefix('0.2.0')).toBe('v0.2.0') - expect(formatVersionWithPrefix('v0.2.0')).toBe('v0.2.0') - expect(formatVersionWithPrefix(null)).toBe('\u2014') - }) + expect(formatVersionWithPrefix("0.2.0")).toBe("v0.2.0"); + expect(formatVersionWithPrefix("v0.2.0")).toBe("v0.2.0"); + expect(formatVersionWithPrefix(null)).toBe("\u2014"); + }); - it('parses valid local daemon version result', async () => { - const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web') + it("parses valid local daemon version result", async () => { + const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web"); - expect(parseLocalDaemonVersionResult({ version: '0.1.15', error: null })).toEqual({ - version: '0.1.15', + expect(parseLocalDaemonVersionResult({ version: "0.1.15", error: null })).toEqual({ + version: "0.1.15", error: null, - }) - }) + }); + }); - it('parses local daemon version error result', async () => { - const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web') + it("parses local daemon version error result", async () => { + const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web"); expect( - parseLocalDaemonVersionResult({ version: null, error: 'paseo command not found in PATH' }) + parseLocalDaemonVersionResult({ version: null, error: "paseo command not found in PATH" }), ).toEqual({ version: null, - error: 'paseo command not found in PATH', - }) - }) + error: "paseo command not found in PATH", + }); + }); - it('parses unexpected local daemon version result', async () => { - const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web') + it("parses unexpected local daemon version result", async () => { + const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web"); expect(parseLocalDaemonVersionResult(null)).toEqual({ version: null, - error: 'Unexpected response from version check.', - }) + error: "Unexpected response from version check.", + }); - expect(parseLocalDaemonVersionResult('not an object')).toEqual({ + expect(parseLocalDaemonVersionResult("not an object")).toEqual({ version: null, - error: 'Unexpected response from version check.', - }) - }) + error: "Unexpected response from version check.", + }); + }); - it('trims whitespace in parsed version', async () => { - const { parseLocalDaemonVersionResult } = await loadModuleForPlatform('web') + it("trims whitespace in parsed version", async () => { + const { parseLocalDaemonVersionResult } = await loadModuleForPlatform("web"); - expect(parseLocalDaemonVersionResult({ version: ' 0.1.15 ', error: null })).toEqual({ - version: '0.1.15', + expect(parseLocalDaemonVersionResult({ version: " 0.1.15 ", error: null })).toEqual({ + version: "0.1.15", error: null, - }) - }) + }); + }); - it('builds copyable daemon update diagnostics', async () => { - const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform('web') + it("builds copyable daemon update diagnostics", async () => { + const { buildDaemonUpdateDiagnostics } = await loadModuleForPlatform("web"); const diagnostics = buildDaemonUpdateDiagnostics({ exitCode: 1, - stdout: 'stdout text', - stderr: 'stderr text', - }) + stdout: "stdout text", + stderr: "stderr text", + }); - expect(diagnostics).toContain('Exit code: 1') - expect(diagnostics).toContain('STDOUT:\nstdout text') - expect(diagnostics).toContain('STDERR:\nstderr text') - }) -}) + expect(diagnostics).toContain("Exit code: 1"); + expect(diagnostics).toContain("STDOUT:\nstdout text"); + expect(diagnostics).toContain("STDERR:\nstderr text"); + }); +}); diff --git a/packages/app/src/desktop/updates/desktop-updates.ts b/packages/app/src/desktop/updates/desktop-updates.ts index a7a11b7e1..64da7f256 100644 --- a/packages/app/src/desktop/updates/desktop-updates.ts +++ b/packages/app/src/desktop/updates/desktop-updates.ts @@ -1,77 +1,77 @@ -import { Platform } from 'react-native' -import { isDesktop } from '@/desktop/host' -import { invokeDesktopCommand } from '@/desktop/electron/invoke' +import { Platform } from "react-native"; +import { isDesktop } from "@/desktop/host"; +import { invokeDesktopCommand } from "@/desktop/electron/invoke"; export interface DesktopAppUpdateCheckResult { - hasUpdate: boolean - currentVersion: string | null - latestVersion: string | null - body: string | null - date: string | null + hasUpdate: boolean; + currentVersion: string | null; + latestVersion: string | null; + body: string | null; + date: string | null; } export interface DesktopAppUpdateInstallResult { - installed: boolean - version: string | null - message: string + installed: boolean; + version: string | null; + message: string; } export interface LocalDaemonUpdateResult { - exitCode: number - stdout: string - stderr: string + exitCode: number; + stdout: string; + stderr: string; } export interface LocalDaemonVersionResult { - version: string | null - error: string | null + version: string | null; + error: string | null; } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null + return typeof value === "object" && value !== null; } function toStringOrNull(value: unknown): string | null { - if (typeof value !== 'string') { - return null + if (typeof value !== "string") { + return null; } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; } function toStringOrEmpty(value: unknown): string { - return typeof value === 'string' ? value : '' + return typeof value === "string" ? value : ""; } function toNumberOr(defaultValue: number, value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) ? value : defaultValue + return typeof value === "number" && Number.isFinite(value) ? value : defaultValue; } export function shouldShowDesktopUpdateSection(): boolean { - return Platform.OS === 'web' && isDesktop() + return Platform.OS === "web" && isDesktop(); } export function parseLocalDaemonVersionResult(raw: unknown): LocalDaemonVersionResult { if (!isRecord(raw)) { - return { version: null, error: 'Unexpected response from version check.' } + return { version: null, error: "Unexpected response from version check." }; } return { version: toStringOrNull(raw.version), error: toStringOrNull(raw.error), - } + }; } export async function getLocalDaemonVersion(): Promise { - const result = await invokeDesktopCommand('get_local_daemon_version') - return parseLocalDaemonVersionResult(result) + const result = await invokeDesktopCommand("get_local_daemon_version"); + return parseLocalDaemonVersionResult(result); } export async function checkDesktopAppUpdate(): Promise { - const result = await invokeDesktopCommand('check_app_update') + const result = await invokeDesktopCommand("check_app_update"); if (!isRecord(result)) { - throw new Error('Unexpected response while checking desktop updates.') + throw new Error("Unexpected response while checking desktop updates."); } return { @@ -80,78 +80,70 @@ export async function checkDesktopAppUpdate(): Promise { - const result = await invokeDesktopCommand('install_app_update') + const result = await invokeDesktopCommand("install_app_update"); if (!isRecord(result)) { - throw new Error('Unexpected response while installing desktop update.') + throw new Error("Unexpected response while installing desktop update."); } return { installed: result.installed === true, version: toStringOrNull(result.version), - message: toStringOrNull(result.message) ?? 'Update completed.', - } + message: toStringOrNull(result.message) ?? "Update completed.", + }; } export async function runLocalDaemonUpdate(): Promise { - const result = await invokeDesktopCommand('run_local_daemon_update') + const result = await invokeDesktopCommand("run_local_daemon_update"); if (!isRecord(result)) { - throw new Error('Unexpected response while updating local daemon.') + throw new Error("Unexpected response while updating local daemon."); } return { exitCode: toNumberOr(1, result.exitCode), stdout: toStringOrEmpty(result.stdout), stderr: toStringOrEmpty(result.stderr), - } + }; } export function normalizeVersionForComparison(version: string | null | undefined): string | null { - const value = version?.trim() + const value = version?.trim(); if (!value) { - return null + return null; } - return value.replace(/^v/i, '') + return value.replace(/^v/i, ""); } export function isVersionMismatch( appVersion: string | null | undefined, - daemonVersion: string | null | undefined + daemonVersion: string | null | undefined, ): boolean { - const app = normalizeVersionForComparison(appVersion) - const daemon = normalizeVersionForComparison(daemonVersion) + const app = normalizeVersionForComparison(appVersion); + const daemon = normalizeVersionForComparison(daemonVersion); if (!app || !daemon) { - return false + return false; } - return app !== daemon + return app !== daemon; } export function formatVersionWithPrefix(version: string | null | undefined): string { - const value = version?.trim() + const value = version?.trim(); if (!value) { - return '\u2014' + return "\u2014"; } - return value.startsWith('v') ? value : `v${value}` + return value.startsWith("v") ? value : `v${value}`; } export function buildDaemonUpdateDiagnostics(result: LocalDaemonUpdateResult): string { - const stdout = result.stdout.length > 0 ? result.stdout : '(empty)' - const stderr = result.stderr.length > 0 ? result.stderr : '(empty)' + const stdout = result.stdout.length > 0 ? result.stdout : "(empty)"; + const stderr = result.stderr.length > 0 ? result.stderr : "(empty)"; - return [ - `Exit code: ${result.exitCode}`, - '', - 'STDOUT:', - stdout, - '', - 'STDERR:', - stderr, - ].join('\n') + return [`Exit code: ${result.exitCode}`, "", "STDOUT:", stdout, "", "STDERR:", stderr].join("\n"); } diff --git a/packages/app/src/desktop/updates/update-banner.tsx b/packages/app/src/desktop/updates/update-banner.tsx index cfe9a07a6..860abad21 100644 --- a/packages/app/src/desktop/updates/update-banner.tsx +++ b/packages/app/src/desktop/updates/update-banner.tsx @@ -10,14 +10,8 @@ const CHANGELOG_URL = "https://paseo.sh/changelog"; export function UpdateBanner() { const { theme } = useUnistyles(); - const { - isDesktop, - status, - availableUpdate, - checkForUpdates, - installUpdate, - isInstalling, - } = useDesktopAppUpdater(); + const { isDesktop, status, availableUpdate, checkForUpdates, installUpdate, isInstalling } = + useDesktopAppUpdater(); const [dismissed, setDismissed] = useState(false); const intervalRef = useRef | null>(null); @@ -46,18 +40,12 @@ export function UpdateBanner() { return ( - setDismissed(true)} - hitSlop={8} - style={styles.closeButton} - > + setDismissed(true)} hitSlop={8} style={styles.closeButton}> - - {isInstalled ? "Update installed" : "Update available"} - + {isInstalled ? "Update installed" : "Update available"} {isInstalled ? "Restart to use the new version." @@ -68,10 +56,7 @@ export function UpdateBanner() { void openExternalUrl(CHANGELOG_URL)} - style={({ pressed }) => [ - styles.outlineButton, - pressed && styles.buttonPressed, - ]} + style={({ pressed }) => [styles.outlineButton, pressed && styles.buttonPressed]} > What's new diff --git a/packages/app/src/desktop/updates/use-desktop-app-updater.ts b/packages/app/src/desktop/updates/use-desktop-app-updater.ts index da7f949a3..c50d795b1 100644 --- a/packages/app/src/desktop/updates/use-desktop-app-updater.ts +++ b/packages/app/src/desktop/updates/use-desktop-app-updater.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useRef, useState } from "react"; import { checkDesktopAppUpdate, formatVersionWithPrefix, @@ -6,163 +6,163 @@ import { shouldShowDesktopUpdateSection, type DesktopAppUpdateCheckResult, type DesktopAppUpdateInstallResult, -} from '@/desktop/updates/desktop-updates' +} from "@/desktop/updates/desktop-updates"; export type DesktopAppUpdateStatus = - | 'idle' - | 'checking' - | 'up-to-date' - | 'available' - | 'installing' - | 'installed' - | 'error' + | "idle" + | "checking" + | "up-to-date" + | "available" + | "installing" + | "installed" + | "error"; export interface UseDesktopAppUpdaterReturn { - isDesktop: boolean - status: DesktopAppUpdateStatus - statusText: string - availableUpdate: DesktopAppUpdateCheckResult | null - errorMessage: string | null - lastCheckedAt: number | null - isChecking: boolean - isInstalling: boolean - checkForUpdates: (options?: { silent?: boolean }) => Promise - installUpdate: () => Promise + isDesktop: boolean; + status: DesktopAppUpdateStatus; + statusText: string; + availableUpdate: DesktopAppUpdateCheckResult | null; + errorMessage: string | null; + lastCheckedAt: number | null; + isChecking: boolean; + isInstalling: boolean; + checkForUpdates: (options?: { silent?: boolean }) => Promise; + installUpdate: () => Promise; } function getErrorMessage(error: unknown): string { - if (error instanceof Error && typeof error.message === 'string') { - return error.message + if (error instanceof Error && typeof error.message === "string") { + return error.message; } - return String(error) + return String(error); } function formatStatusText(input: { - status: DesktopAppUpdateStatus - availableUpdate: DesktopAppUpdateCheckResult | null - installMessage: string | null + status: DesktopAppUpdateStatus; + availableUpdate: DesktopAppUpdateCheckResult | null; + installMessage: string | null; }): string { - const { status, availableUpdate, installMessage } = input + const { status, availableUpdate, installMessage } = input; - if (status === 'checking') { - return 'Checking for app updates...' + if (status === "checking") { + return "Checking for app updates..."; } - if (status === 'installing') { - return 'Installing app update...' + if (status === "installing") { + return "Installing app update..."; } - if (status === 'up-to-date') { - return 'App is up to date.' + if (status === "up-to-date") { + return "App is up to date."; } - if (status === 'available') { + if (status === "available") { if (availableUpdate?.latestVersion) { - return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}` + return `Update available: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`; } - return 'An app update is available.' + return "An app update is available."; } - if (status === 'installed') { - return installMessage ?? 'App update installed. Restart required.' + if (status === "installed") { + return installMessage ?? "App update installed. Restart required."; } - if (status === 'error') { - return 'Failed to update app.' + if (status === "error") { + return "Failed to update app."; } - return 'Update status has not been checked yet.' + return "Update status has not been checked yet."; } export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { - const isDesktop = shouldShowDesktopUpdateSection() - const requestVersionRef = useRef(0) - const [status, setStatus] = useState('idle') - const [availableUpdate, setAvailableUpdate] = useState(null) - const [errorMessage, setErrorMessage] = useState(null) - const [installMessage, setInstallMessage] = useState(null) - const [lastCheckedAt, setLastCheckedAt] = useState(null) + const isDesktop = shouldShowDesktopUpdateSection(); + const requestVersionRef = useRef(0); + const [status, setStatus] = useState("idle"); + const [availableUpdate, setAvailableUpdate] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [installMessage, setInstallMessage] = useState(null); + const [lastCheckedAt, setLastCheckedAt] = useState(null); const checkForUpdates = useCallback( async (options: { silent?: boolean } = {}) => { if (!isDesktop) { - return null + return null; } - const requestVersion = requestVersionRef.current + 1 - requestVersionRef.current = requestVersion + const requestVersion = requestVersionRef.current + 1; + requestVersionRef.current = requestVersion; if (!options.silent) { - setStatus('checking') + setStatus("checking"); } - setErrorMessage(null) + setErrorMessage(null); try { - const result = await checkDesktopAppUpdate() + const result = await checkDesktopAppUpdate(); if (requestVersion !== requestVersionRef.current) { - return result + return result; } - setInstallMessage(null) - setLastCheckedAt(Date.now()) + setInstallMessage(null); + setLastCheckedAt(Date.now()); if (result.hasUpdate) { - setAvailableUpdate(result) - setStatus('available') + setAvailableUpdate(result); + setStatus("available"); } else { - setAvailableUpdate(null) - setStatus('up-to-date') + setAvailableUpdate(null); + setStatus("up-to-date"); } - return result + return result; } catch (error) { if (requestVersion !== requestVersionRef.current) { - return null + return null; } - const message = getErrorMessage(error) + const message = getErrorMessage(error); if (options.silent) { - console.warn('[DesktopUpdater] Silent update check failed', message) + console.warn("[DesktopUpdater] Silent update check failed", message); } else { - setStatus('error') - setErrorMessage(message) + setStatus("error"); + setErrorMessage(message); } - return null + return null; } }, - [isDesktop] - ) + [isDesktop], + ); const installUpdate = useCallback(async () => { if (!isDesktop) { - return null + return null; } - setStatus('installing') - setErrorMessage(null) + setStatus("installing"); + setErrorMessage(null); try { - const result = await installDesktopAppUpdate() - setLastCheckedAt(Date.now()) + const result = await installDesktopAppUpdate(); + setLastCheckedAt(Date.now()); if (result.installed) { - setAvailableUpdate(null) - setInstallMessage(result.message) - setStatus('installed') + setAvailableUpdate(null); + setInstallMessage(result.message); + setStatus("installed"); } else { - setAvailableUpdate(null) - setInstallMessage(result.message) - setStatus('up-to-date') + setAvailableUpdate(null); + setInstallMessage(result.message); + setStatus("up-to-date"); } - return result + return result; } catch (error) { - const message = getErrorMessage(error) - setStatus('error') - setErrorMessage(message) - return null + const message = getErrorMessage(error); + setStatus("error"); + setErrorMessage(message); + return null; } - }, [isDesktop]) + }, [isDesktop]); return { isDesktop, @@ -175,9 +175,9 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn { availableUpdate, errorMessage, lastCheckedAt, - isChecking: status === 'checking', - isInstalling: status === 'installing', + isChecking: status === "checking", + isInstalling: status === "installing", checkForUpdates, installUpdate, - } + }; } diff --git a/packages/app/src/dictation/dictation-stream-sender.test.ts b/packages/app/src/dictation/dictation-stream-sender.test.ts index 3ebc4f1b1..d7c6d9eaf 100644 --- a/packages/app/src/dictation/dictation-stream-sender.test.ts +++ b/packages/app/src/dictation/dictation-stream-sender.test.ts @@ -21,7 +21,10 @@ class FakeDaemonClient { this.chunks.push({ dictationId, seq, audio, format }); } - async finishDictationStream(dictationId: string, finalSeq: number): Promise<{ dictationId: string; text: string }> { + async finishDictationStream( + dictationId: string, + finalSeq: number, + ): Promise<{ dictationId: string; text: string }> { this.finishes.push({ dictationId, finalSeq }); return { dictationId, text: "ok" }; } @@ -123,4 +126,3 @@ describe("DictationStreamSender", () => { expect(client.chunks.map((c) => c.seq)).toEqual([0, 1]); }); }); - diff --git a/packages/app/src/dictation/dictation-stream-sender.ts b/packages/app/src/dictation/dictation-stream-sender.ts index ec0dc7ae7..a4bbf218e 100644 --- a/packages/app/src/dictation/dictation-stream-sender.ts +++ b/packages/app/src/dictation/dictation-stream-sender.ts @@ -139,18 +139,20 @@ export class DictationStreamSender { } this.streamReady = true; this.flush(); - })().catch((error) => { - // If starting failed, keep the segments for retry but clear the stream so finish can error cleanly. - if (this.startGeneration === generation && this.dictationId === dictationId) { - this.dictationId = null; - this.streamReady = false; - } - throw error; - }).finally(() => { - if (this.startPromise === start) { - this.startPromise = null; - } - }); + })() + .catch((error) => { + // If starting failed, keep the segments for retry but clear the stream so finish can error cleanly. + if (this.startGeneration === generation && this.dictationId === dictationId) { + this.dictationId = null; + this.streamReady = false; + } + throw error; + }) + .finally(() => { + if (this.startPromise === start) { + this.startPromise = null; + } + }); this.startPromise = start; await start; diff --git a/packages/app/src/hooks/checkout-diff-order.test.ts b/packages/app/src/hooks/checkout-diff-order.test.ts index abe606cb9..f00231bf9 100644 --- a/packages/app/src/hooks/checkout-diff-order.test.ts +++ b/packages/app/src/hooks/checkout-diff-order.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - compareCheckoutDiffPaths, - orderCheckoutDiffFiles, -} from "./checkout-diff-order"; +import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order"; function createFile(path: string, additions = 0) { return { @@ -29,11 +26,7 @@ describe("checkout diff ordering", () => { createFile("beta.ts"), ]); - expect(ordered.map((file) => file.path)).toEqual([ - "alpha.ts", - "beta.ts", - "zeta.ts", - ]); + expect(ordered.map((file) => file.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]); }); it("preserves relative order for equal paths", () => { diff --git a/packages/app/src/hooks/checkout-diff-order.ts b/packages/app/src/hooks/checkout-diff-order.ts index 10abd139e..6cb20cf67 100644 --- a/packages/app/src/hooks/checkout-diff-order.ts +++ b/packages/app/src/hooks/checkout-diff-order.ts @@ -9,9 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number { return left < right ? -1 : 1; } -export function orderCheckoutDiffFiles( - files: ParsedDiffFile[] -): ParsedDiffFile[] { +export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] { if (files.length < 2) { return files; } diff --git a/packages/app/src/hooks/checkout-status-revalidation.ts b/packages/app/src/hooks/checkout-status-revalidation.ts index e2d2e1715..d1174b55f 100644 --- a/packages/app/src/hooks/checkout-status-revalidation.ts +++ b/packages/app/src/hooks/checkout-status-revalidation.ts @@ -5,7 +5,9 @@ export type CheckoutStatusRevalidationParams = { explorerTab: string; }; -export function checkoutStatusRevalidationKey(params: CheckoutStatusRevalidationParams): string | null { +export function checkoutStatusRevalidationKey( + params: CheckoutStatusRevalidationParams, +): string | null { if (!params.cwd) return null; if (!params.isOpen) return null; if (params.explorerTab !== "changes") return null; @@ -14,10 +16,9 @@ export function checkoutStatusRevalidationKey(params: CheckoutStatusRevalidation export function nextCheckoutStatusRefetchDecision( prevKey: string | null, - nextKey: string | null + nextKey: string | null, ): { nextSeenKey: string | null; shouldRefetch: boolean } { if (!nextKey) return { nextSeenKey: null, shouldRefetch: false }; if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false }; return { nextSeenKey: nextKey, shouldRefetch: true }; } - diff --git a/packages/app/src/hooks/image-attachment-picker.test.ts b/packages/app/src/hooks/image-attachment-picker.test.ts index 6ccbfe28c..ad7ea2238 100644 --- a/packages/app/src/hooks/image-attachment-picker.test.ts +++ b/packages/app/src/hooks/image-attachment-picker.test.ts @@ -82,7 +82,7 @@ describe("image-attachment-picker", () => { multiple: true, directory: false, title: "Attach images", - }) + }), ); expect(result).toEqual(["/tmp/one.png", "/tmp/two.jpg"]); }); @@ -91,7 +91,7 @@ describe("image-attachment-picker", () => { desktopHostState.api = {}; await expect(openImagePathsWithDesktopDialog()).rejects.toThrow( - "Desktop dialog API is not available." + "Desktop dialog API is not available.", ); }); }); diff --git a/packages/app/src/hooks/image-attachment-picker.ts b/packages/app/src/hooks/image-attachment-picker.ts index bf28e2294..c476a48a9 100644 --- a/packages/app/src/hooks/image-attachment-picker.ts +++ b/packages/app/src/hooks/image-attachment-picker.ts @@ -1,8 +1,6 @@ import { getDesktopHost } from "@/desktop/host"; -export type PickedImageSource = - | { kind: "file_uri"; uri: string } - | { kind: "blob"; blob: Blob }; +export type PickedImageSource = { kind: "file_uri"; uri: string } | { kind: "blob"; blob: Blob }; export interface PickedImageAttachmentInput { source: PickedImageSource; @@ -48,7 +46,7 @@ async function blobFromUri(uri: string): Promise { } export async function normalizePickedImageAssets( - assets: readonly ExpoImagePickerAssetLike[] + assets: readonly ExpoImagePickerAssetLike[], ): Promise { return await Promise.all( assets.map(async (asset) => { @@ -73,7 +71,7 @@ export async function normalizePickedImageAssets( mimeType: asset.mimeType ?? null, fileName: asset.fileName ?? null, }; - }) + }), ); } diff --git a/packages/app/src/hooks/use-agent-attention-clear.ts b/packages/app/src/hooks/use-agent-attention-clear.ts index 20a7cfe72..6282df340 100644 --- a/packages/app/src/hooks/use-agent-attention-clear.ts +++ b/packages/app/src/hooks/use-agent-attention-clear.ts @@ -32,9 +32,7 @@ export function useAgentAttentionClear({ attentionReason, isScreenFocused, }: UseAgentAttentionClearParams): AgentAttentionClearController { - const [isAppVisible, setIsAppVisible] = useState(() => - getIsAppActivelyVisible() - ); + const [isAppVisible, setIsAppVisible] = useState(() => getIsAppActivelyVisible()); const deferredFocusEntryClearRef = useRef(false); const prevRequiresAttentionRef = useRef(Boolean(requiresAttention)); const prevActivelyViewedRef = useRef(isScreenFocused && getIsAppActivelyVisible()); @@ -62,7 +60,7 @@ export function useAgentAttentionClear({ deferredFocusEntryClearRef.current = false; client.clearAgentAttention(resolvedAgentId); }, - [agentId, attentionReason, client, isConnected, requiresAttention] + [agentId, attentionReason, client, isConnected, requiresAttention], ); useEffect(() => { @@ -70,10 +68,7 @@ export function useAgentAttentionClear({ setIsAppVisible(getIsAppActivelyVisible()); }; - const appStateSubscription = AppState.addEventListener( - "change", - updateVisibility - ); + const appStateSubscription = AppState.addEventListener("change", updateVisibility); if (Platform.OS === "web" && typeof document !== "undefined") { document.addEventListener("visibilitychange", updateVisibility); @@ -114,10 +109,8 @@ export function useAgentAttentionClear({ }, [isAppVisible, isScreenFocused, requiresAttention]); useEffect(() => { - const enteredScreenFocus = - !prevScreenFocusedRef.current && isScreenFocused && isAppVisible; - const resumedIntoFocusedAgent = - !prevAppVisibleRef.current && isAppVisible && isScreenFocused; + const enteredScreenFocus = !prevScreenFocusedRef.current && isScreenFocused && isAppVisible; + const resumedIntoFocusedAgent = !prevAppVisibleRef.current && isAppVisible && isScreenFocused; if (enteredScreenFocus || resumedIntoFocusedAgent) { clearAttention("focus-entry"); diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 491e0fe1a..8090c28a8 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -1,97 +1,97 @@ -import { useCallback, useMemo } from 'react' -import { keepPreviousData, useQuery } from '@tanstack/react-query' -import type { AutocompleteOption } from '@/components/ui/autocomplete' -import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query' -import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils' -import { useAutocomplete } from './use-autocomplete' -import { useSessionStore } from '@/stores/session-store' -import { useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' +import { useCallback, useMemo } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import type { AutocompleteOption } from "@/components/ui/autocomplete"; +import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query"; +import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; +import { useAutocomplete } from "./use-autocomplete"; +import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { applyFileMentionReplacement, findActiveFileMention, type FileMentionRange, -} from '@/utils/file-mention-autocomplete' +} from "@/utils/file-mention-autocomplete"; interface UseAgentAutocompleteInput { - userInput: string - cursorIndex: number - setUserInput: (nextValue: string) => void - serverId: string - agentId: string - draftConfig?: DraftCommandConfig - onAutocompleteApplied?: () => void + userInput: string; + cursorIndex: number; + setUserInput: (nextValue: string) => void; + serverId: string; + agentId: string; + draftConfig?: DraftCommandConfig; + onAutocompleteApplied?: () => void; } type AgentAutocompleteOption = - | (AutocompleteOption & { type: 'command' }) + | (AutocompleteOption & { type: "command" }) | (AutocompleteOption & { - type: 'workspace_entry' - entryPath: string - mention: FileMentionRange - }) + type: "workspace_entry"; + entryPath: string; + mention: FileMentionRange; + }); interface AgentAutocompleteResult { - isVisible: boolean - options: AutocompleteOption[] - selectedIndex: number - isLoading: boolean - errorMessage?: string - loadingText: string - emptyText: string - onSelectOption: (option: AutocompleteOption) => void - onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean + isVisible: boolean; + options: AutocompleteOption[]; + selectedIndex: number; + isLoading: boolean; + errorMessage?: string; + loadingText: string; + emptyText: string; + onSelectOption: (option: AutocompleteOption) => void; + onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; } interface DirectorySuggestionEntry { - path: string - kind: 'file' | 'directory' + path: string; + kind: "file" | "directory"; } function normalizeDraftCommandConfig( - draftConfig?: DraftCommandConfig + draftConfig?: DraftCommandConfig, ): DraftCommandConfig | undefined { if (!draftConfig) { - return undefined + return undefined; } - const cwd = draftConfig.cwd.trim() + const cwd = draftConfig.cwd.trim(); if (!cwd) { - return undefined + return undefined; } - const modeId = draftConfig.modeId?.trim() ?? '' - const model = draftConfig.model?.trim() ?? '' - const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? '' + const modeId = draftConfig.modeId?.trim() ?? ""; + const model = draftConfig.model?.trim() ?? ""; + const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; return { provider: draftConfig.provider, cwd, ...(modeId ? { modeId } : {}), ...(model ? { model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), - } + }; } function mapDirectorySuggestionsToEntries(payload: { - entries?: Array<{ path: string; kind: string }> - directories?: string[] + entries?: Array<{ path: string; kind: string }>; + directories?: string[]; }): DirectorySuggestionEntry[] { if (Array.isArray(payload.entries) && payload.entries.length > 0) { return payload.entries.flatMap((entry) => { if ( !entry || - typeof entry.path !== 'string' || - (entry.kind !== 'file' && entry.kind !== 'directory') + typeof entry.path !== "string" || + (entry.kind !== "file" && entry.kind !== "directory") ) { - return [] + return []; } - return [{ path: entry.path, kind: entry.kind }] - }) + return [{ path: entry.path, kind: entry.kind }]; + }); } return (payload.directories ?? []).map((path) => ({ path, - kind: 'directory' as const, - })) + kind: "directory" as const, + })); } export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult { @@ -103,10 +103,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut agentId, draftConfig, onAutocompleteApplied, - } = input + } = input; - const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ') - const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : '' + const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); + const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ""; const activeFileMention = useMemo( () => @@ -114,44 +114,44 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut text: userInput, cursorIndex, }), - [cursorIndex, userInput] - ) - const showFileAutocomplete = activeFileMention !== null - const fileFilterQuery = activeFileMention?.query ?? '' + [cursorIndex, userInput], + ); + const showFileAutocomplete = activeFileMention !== null; + const fileFilterQuery = activeFileMention?.query ?? ""; const normalizedDraftConfig = useMemo( () => normalizeDraftCommandConfig(draftConfig), - [draftConfig] - ) + [draftConfig], + ); - const isDraftContext = normalizedDraftConfig !== undefined - const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined - const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext) + const isDraftContext = normalizedDraftConfig !== undefined; + const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined; + const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext); const agentCwd = useSessionStore( - (state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? '' - ) + (state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "", + ); const autocompleteCwd = useMemo(() => { if (isDraftContext) { - return queryDraftConfig?.cwd ?? '' + return queryDraftConfig?.cwd ?? ""; } - return agentCwd.trim() - }, [agentCwd, isDraftContext, queryDraftConfig]) + return agentCwd.trim(); + }, [agentCwd, isDraftContext, queryDraftConfig]); - const client = useHostRuntimeClient(serverId) - const isConnected = useHostRuntimeIsConnected(serverId) + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); - const mode: 'command' | 'file' | null = showFileAutocomplete - ? 'file' + const mode: "command" | "file" | null = showFileAutocomplete + ? "file" : showCommandAutocomplete - ? 'command' - : null + ? "command" + : null; const isVisible = - mode === 'command' + mode === "command" ? canLoadCommands - : mode === 'file' + : mode === "file" ? Boolean(serverId) && autocompleteCwd.length > 0 - : false + : false; const { commands, @@ -161,15 +161,15 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut } = useAgentCommandsQuery({ serverId, agentId, - enabled: mode === 'command' && canLoadCommands, + enabled: mode === "command" && canLoadCommands, draftConfig: queryDraftConfig, - }) + }); const fileSuggestionsQuery = useQuery({ - queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true], + queryKey: ["directorySuggestions", serverId, autocompleteCwd, fileFilterQuery, true, true], queryFn: async (): Promise => { if (!client) { - throw new Error('Daemon client unavailable') + throw new Error("Daemon client unavailable"); } const response = await client.getDirectorySuggestions({ cwd: autocompleteCwd, @@ -177,14 +177,14 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut limit: 50, includeFiles: true, includeDirectories: true, - }) + }); if (response.error) { - throw new Error(response.error) + throw new Error(response.error); } - return mapDirectorySuggestionsToEntries(response) + return mapDirectorySuggestionsToEntries(response); }, enabled: - mode === 'file' && + mode === "file" && Boolean(serverId) && autocompleteCwd.length > 0 && Boolean(client) && @@ -192,89 +192,89 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut retry: false, staleTime: 15_000, placeholderData: keepPreviousData, - }) + }); const options = useMemo(() => { if (!isVisible) { - return [] + return []; } - if (mode === 'command') { - const filterLower = commandFilterQuery.toLowerCase() - const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower)) - const orderedMatches = orderAutocompleteOptions(matches) + if (mode === "command") { + const filterLower = commandFilterQuery.toLowerCase(); + const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower)); + const orderedMatches = orderAutocompleteOptions(matches); return orderedMatches.map((cmd) => ({ - type: 'command' as const, + type: "command" as const, id: cmd.name, label: `/${cmd.name}`, detail: cmd.argumentHint || undefined, description: cmd.description, - kind: 'command', - })) + kind: "command", + })); } - if (mode === 'file' && activeFileMention) { - const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []) + if (mode === "file" && activeFileMention) { + const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []); return orderedEntries.map((entry) => ({ - type: 'workspace_entry' as const, + type: "workspace_entry" as const, id: `${entry.kind}:${entry.path}`, label: entry.path, kind: entry.kind, entryPath: entry.path, mention: activeFileMention, - })) + })); } - return [] - }, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]) + return []; + }, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]); const onSelectOption = useCallback( (option: AutocompleteOption) => { - const selected = option as AgentAutocompleteOption - if (selected.type === 'command') { - setUserInput(`/${selected.id} `) - onAutocompleteApplied?.() - return + const selected = option as AgentAutocompleteOption; + if (selected.type === "command") { + setUserInput(`/${selected.id} `); + onAutocompleteApplied?.(); + return; } const nextInput = applyFileMentionReplacement({ text: userInput, mention: selected.mention, relativePath: selected.entryPath, - }) - setUserInput(nextInput) - onAutocompleteApplied?.() + }); + setUserInput(nextInput); + onAutocompleteApplied?.(); }, - [onAutocompleteApplied, setUserInput, userInput] - ) + [onAutocompleteApplied, setUserInput, userInput], + ); const { selectedIndex, onKeyPress } = useAutocomplete({ isVisible, options, - query: mode === 'command' ? commandFilterQuery : fileFilterQuery, + query: mode === "command" ? commandFilterQuery : fileFilterQuery, onSelectOption, - onEscape: mode === 'command' ? () => setUserInput('') : undefined, - }) + onEscape: mode === "command" ? () => setUserInput("") : undefined, + }); const isLoading = - mode === 'command' + mode === "command" ? isCommandsLoading - : mode === 'file' + : mode === "file" ? fileSuggestionsQuery.isPending || (fileSuggestionsQuery.isLoading && options.length === 0) - : false + : false; const errorMessage = - mode === 'command' + mode === "command" ? isError - ? (error?.message ?? 'Failed to load') + ? (error?.message ?? "Failed to load") : undefined - : mode === 'file' + : mode === "file" ? fileSuggestionsQuery.error instanceof Error ? fileSuggestionsQuery.error.message : undefined - : undefined + : undefined; - const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...' - const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found' + const loadingText = mode === "file" ? "Searching workspace..." : "Loading commands..."; + const emptyText = mode === "file" ? "No files or directories found" : "No commands found"; return { isVisible, @@ -286,5 +286,5 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut emptyText, onSelectOption, onKeyPress, - } + }; } diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 63c16e5d0..518f0c372 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -18,11 +18,7 @@ export interface DraftCommandConfig { thinkingOptionId?: string; } -function commandsQueryKey( - serverId: string, - agentId: string, - draftConfig?: DraftCommandConfig -) { +function commandsQueryKey(serverId: string, agentId: string, draftConfig?: DraftCommandConfig) { return [ "agentCommands", serverId, diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index 92154676a..630f4c051 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -4,10 +4,7 @@ import { AGENT_PROVIDER_DEFINITIONS, type AgentProviderDefinition, } from "@server/server/agent/provider-manifest"; -import type { - AgentModelDefinition, - AgentProvider, -} from "@server/server/agent/agent-sdk-types"; +import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; describe("useAgentFormState", () => { describe("__private__.combineInitialValues", () => { @@ -80,7 +77,7 @@ describe("useAgentFormState", () => { thinkingOptionId: "", workingDir: "", }, - new Set() + new Set(), ); expect(resolved.thinkingOptionId).toBe("xhigh"); @@ -114,7 +111,7 @@ describe("useAgentFormState", () => { thinkingOptionId: "", workingDir: "", }, - new Set() + new Set(), ); expect(resolved.thinkingOptionId).toBe("low"); @@ -148,7 +145,7 @@ describe("useAgentFormState", () => { thinkingOptionId: "", workingDir: "", }, - new Set() + new Set(), ); expect(resolved.thinkingOptionId).toBe("xhigh"); @@ -175,7 +172,7 @@ describe("useAgentFormState", () => { thinkingOptionId: "", workingDir: "", }, - new Set() + new Set(), ); expect(resolved.model).toBe(""); @@ -209,7 +206,7 @@ describe("useAgentFormState", () => { thinkingOptionId: "", workingDir: "", }, - new Set() + new Set(), ); expect(resolved.model).toBe(""); @@ -217,9 +214,9 @@ describe("useAgentFormState", () => { it("resolves provider only from allowed provider map", () => { const allowedProviderMap = new Map( - AGENT_PROVIDER_DEFINITIONS - .filter((definition) => definition.id === "claude") - .map((definition) => [definition.id as AgentProvider, definition]) + AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map( + (definition) => [definition.id as AgentProvider, definition], + ), ); const resolved = __private__.resolveFormState( undefined, @@ -242,7 +239,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), - allowedProviderMap + allowedProviderMap, ); expect(resolved.provider).toBe("claude"); @@ -270,7 +267,7 @@ describe("useAgentFormState", () => { workingDir: "", }, new Set(), - new Map() + new Map(), ); expect(resolved.provider).toBe("codex"); diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 829ecfa8b..d2af3da12 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -94,16 +94,13 @@ type UseAgentFormStateResult = { const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS; const allProviderDefinitionMap = new Map( - allProviderDefinitions.map((definition) => [definition.id, definition]) + allProviderDefinitions.map((definition) => [definition.id, definition]), ); const fallbackDefinition = allProviderDefinitions[0]; const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude"; -const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = - fallbackDefinition?.defaultModeId ?? ""; +const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? ""; -function normalizeSelectedModelId( - modelId: string | null | undefined -): string { +function normalizeSelectedModelId(modelId: string | null | undefined): string { const normalized = typeof modelId === "string" ? modelId.trim() : ""; if (!normalized || normalized.toLowerCase() === "default") { return ""; @@ -112,7 +109,7 @@ function normalizeSelectedModelId( } function resolveDefaultModel( - availableModels: AgentModelDefinition[] | null + availableModels: AgentModelDefinition[] | null, ): AgentModelDefinition | null { if (!availableModels || availableModels.length === 0) { return null; @@ -122,7 +119,7 @@ function resolveDefaultModel( function resolveEffectiveModel( availableModels: AgentModelDefinition[] | null, - modelId: string + modelId: string, ): AgentModelDefinition | null { if (!availableModels || availableModels.length === 0) { return null; @@ -150,13 +147,11 @@ function resolveFormState( userModified: UserModifiedFields, currentState: FormState, validServerIds: Set, - allowedProviderMap: Map = allProviderDefinitionMap + allowedProviderMap: Map = allProviderDefinitionMap, ): FormState { // Start with current state - we only update non-user-modified fields const result = { ...currentState }; - const fallbackProvider = allowedProviderMap.keys().next().value as - | AgentProvider - | undefined; + const fallbackProvider = allowedProviderMap.keys().next().value as AgentProvider | undefined; // 1. Resolve provider first (other fields depend on it) if (!userModified.provider) { @@ -188,10 +183,7 @@ function resolveFormState( validModeIds.includes(initialValues.modeId) ) { result.modeId = initialValues.modeId; - } else if ( - providerPrefs?.mode && - validModeIds.includes(providerPrefs.mode) - ) { + } else if (providerPrefs?.mode && validModeIds.includes(providerPrefs.mode)) { result.modeId = providerPrefs.mode; } else { result.modeId = providerDef?.defaultModeId ?? validModeIds[0] ?? ""; @@ -200,8 +192,7 @@ function resolveFormState( // 3. Resolve model (depends on provider + availableModels) if (!userModified.model) { - const isValidModel = (m: string) => - availableModels?.some((am) => am.id === m) ?? false; + const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false; const initialModel = normalizeSelectedModelId(initialValues?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model); @@ -233,8 +224,7 @@ function resolveFormState( typeof initialValues?.thinkingOptionId === "string" ? initialValues.thinkingOptionId.trim() : ""; - const preferredThinkingOptionId = - providerPrefs?.thinkingOptionId?.trim() ?? ""; + const preferredThinkingOptionId = providerPrefs?.thinkingOptionId?.trim() ?? ""; if (!userModified.thinkingOptionId) { if (initialThinkingOptionId.length > 0) { @@ -255,9 +245,7 @@ function resolveFormState( } else { const thinkingIds = new Set(thinkingOptions.map((option) => option.id)); const defaultThinkingOptionId = - effectiveModel?.defaultThinkingOptionId ?? - thinkingOptions[0]?.id ?? - ""; + effectiveModel?.defaultThinkingOptionId ?? thinkingOptions[0]?.id ?? ""; if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) { result.thinkingOptionId = defaultThinkingOptionId; } @@ -290,7 +278,7 @@ function resolveFormState( function combineInitialValues( initialValues: FormInitialValues | undefined, - initialServerId: string | null + initialServerId: string | null, ): FormInitialValues | undefined { const hasExplicitServerId = initialValues?.serverId !== undefined; const serverIdFromOptions = initialServerId === null ? undefined : initialServerId; @@ -311,9 +299,7 @@ function combineInitialValues( return initialValues; } -export function useAgentFormState( - options: UseAgentFormStateOptions = {} -): UseAgentFormStateResult { +export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult { const { initialServerId = null, initialValues, @@ -333,10 +319,7 @@ export function useAgentFormState( const daemons = useHosts(); // Build a set of valid server IDs for preference validation - const validServerIds = useMemo( - () => new Set(daemons.map((d) => d.serverId)), - [daemons] - ); + const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]); // Track which fields the user has explicitly modified const [userModified, setUserModified] = useState(INITIAL_USER_MODIFIED); @@ -373,7 +356,7 @@ export function useAgentFormState( const availableProvidersQuery = useQuery({ queryKey: ["availableProviders", formState.serverId], enabled: Boolean( - isVisible && isTargetDaemonReady && formState.serverId && client && isConnected + isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, ), staleTime: 60 * 1000, queryFn: async () => { @@ -384,9 +367,7 @@ export function useAgentFormState( if (payload.error) { throw new Error(payload.error); } - return payload.providers - .filter((entry) => entry.available) - .map((entry) => entry.provider); + return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider); }, }); @@ -397,16 +378,16 @@ export function useAgentFormState( } const available = new Set(availableProviders); return allProviderDefinitions.filter((definition) => - available.has(definition.id as AgentProvider) + available.has(definition.id as AgentProvider), ); }, [availableProvidersQuery.data]); const providerDefinitionMap = useMemo( () => new Map( - providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]) + providerDefinitions.map((definition) => [definition.id as AgentProvider, definition]), ), - [providerDefinitions] + [providerDefinitions], ); const [debouncedCwd, setDebouncedCwd] = useState(undefined); @@ -425,7 +406,7 @@ export function useAgentFormState( formState.serverId && client && isConnected && - providerDefinitionMap.has(formState.provider) + providerDefinitionMap.has(formState.provider), ), staleTime: 5 * 60 * 1000, queryFn: async () => { @@ -448,11 +429,7 @@ export function useAgentFormState( queries: providerDefinitions.map((def) => ({ queryKey: ["providerModels", formState.serverId, def.id, debouncedCwd], enabled: Boolean( - isVisible && - isTargetDaemonReady && - formState.serverId && - client && - isConnected + isVisible && isTargetDaemonReady && formState.serverId && client && isConnected, ), staleTime: 5 * 60 * 1000, queryFn: async () => { @@ -506,7 +483,7 @@ export function useAgentFormState( userModified, formStateRef.current, validServerIds, - providerDefinitionMap + providerDefinitionMap, ); // Only update if something changed @@ -586,7 +563,7 @@ export function useAgentFormState( setUserModified((prev) => ({ ...prev, serverId: true })); void updatePreferences({ serverId: value ?? undefined }); }, - [updatePreferences] + [updatePreferences], ); const setProviderFromUser = useCallback( @@ -608,7 +585,7 @@ export function useAgentFormState( thinkingOptionId: providerPrefs?.thinkingOptionId ?? "", })); }, - [preferences?.providerPreferences, providerDefinitionMap, updatePreferences] + [preferences?.providerPreferences, providerDefinitionMap, updatePreferences], ); const setProviderAndModelFromUser = useCallback( @@ -627,7 +604,12 @@ export function useAgentFormState( void updatePreferences({ provider }); void updateProviderPreferences(provider, { model: modelId }); }, - [preferences?.providerPreferences, providerDefinitionMap, updatePreferences, updateProviderPreferences] + [ + preferences?.providerPreferences, + providerDefinitionMap, + updatePreferences, + updateProviderPreferences, + ], ); const setModeFromUser = useCallback( @@ -636,7 +618,7 @@ export function useAgentFormState( setUserModified((prev) => ({ ...prev, modeId: true })); void updateProviderPreferences(formState.provider, { mode: modeId }); }, - [formState.provider, updateProviderPreferences] + [formState.provider, updateProviderPreferences], ); const setModelFromUser = useCallback( @@ -646,7 +628,7 @@ export function useAgentFormState( setUserModified((prev) => ({ ...prev, model: true })); void updateProviderPreferences(formState.provider, { model: normalizedModelId }); }, - [formState.provider, updateProviderPreferences] + [formState.provider, updateProviderPreferences], ); const setThinkingOptionFromUser = useCallback( @@ -655,7 +637,7 @@ export function useAgentFormState( setUserModified((prev) => ({ ...prev, thinkingOptionId: true })); void updateProviderPreferences(formState.provider, { thinkingOptionId }); }, - [formState.provider, updateProviderPreferences] + [formState.provider, updateProviderPreferences], ); const setWorkingDir = useCallback((value: string) => { @@ -668,7 +650,7 @@ export function useAgentFormState( setUserModified((prev) => ({ ...prev, workingDir: true })); void updatePreferences({ workingDir: value }); }, - [updatePreferences] + [updatePreferences], ); const setSelectedServerId = useCallback((value: string | null) => { @@ -780,7 +762,7 @@ export function useAgentFormState( setProviderAndModelFromUser, workingDirIsEmpty, persistFormPreferences, - ] + ], ); } diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 443f920f9..dd32a15d6 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -11,7 +11,7 @@ describe("useAgentInitialization timeline request policy", () => { }, hasAuthoritativeHistory: false, initialTimelineLimit: 200, - }) + }), ).toEqual({ direction: "tail", limit: 200, @@ -25,7 +25,7 @@ describe("useAgentInitialization timeline request policy", () => { cursor: null, hasAuthoritativeHistory: true, initialTimelineLimit: 200, - }) + }), ).toEqual({ direction: "tail", limit: 200, @@ -42,7 +42,7 @@ describe("useAgentInitialization timeline request policy", () => { }, hasAuthoritativeHistory: true, initialTimelineLimit: 200, - }) + }), ).toEqual({ direction: "after", cursor: { epoch: "epoch-1", seq: 42 }, @@ -57,7 +57,7 @@ describe("useAgentInitialization timeline request policy", () => { cursor: null, hasAuthoritativeHistory: false, initialTimelineLimit: 0, - }) + }), ).toEqual({ direction: "tail", limit: 0, @@ -66,8 +66,6 @@ describe("useAgentInitialization timeline request policy", () => { }); it("does not expose an RPC-success init fallback", () => { - expect( - "shouldResolveInitFromRpcSuccess" in __private__ - ).toBe(false); + expect("shouldResolveInitFromRpcSuccess" in __private__).toBe(false); }); }); diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index e4a36292a..62ffa9a9d 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,9 +1,7 @@ import { useCallback } from "react"; import { Platform } from "react-native"; import { useSessionStore } from "@/stores/session-store"; -import type { - DaemonClient, -} from "@server/client/daemon-client"; +import type { DaemonClient } from "@server/client/daemon-client"; import { attachInitTimeout, createInitDeferred, @@ -18,9 +16,7 @@ const NATIVE_INITIAL_TIMELINE_LIMIT = 200; const UNBOUNDED_TIMELINE_LIMIT = 0; function resolveInitialTimelineLimit(): number { - return Platform.OS === "web" - ? UNBOUNDED_TIMELINE_LIMIT - : NATIVE_INITIAL_TIMELINE_LIMIT; + return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT; } export const __private__ = { @@ -47,7 +43,7 @@ export function useAgentInitialization({ return next; }); }, - [serverId, setInitializingAgents] + [serverId, setInitializingAgents], ); const ensureAgentIsInitialized = useCallback( @@ -64,23 +60,18 @@ export function useAgentInitialization({ const hasAuthoritativeHistory = session?.agentAuthoritativeHistoryApplied.get(agentId) === true; const timelineRequest = deriveInitialTimelineRequest({ - cursor: cursor - ? { epoch: cursor.epoch, seq: cursor.endSeq } - : null, + cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null, hasAuthoritativeHistory, initialTimelineLimit, }); - const initRequestDirection = - timelineRequest.direction === "after" ? "after" : "tail"; + const initRequestDirection = timelineRequest.direction === "after" ? "after" : "tail"; const deferred = createInitDeferred(key, initRequestDirection); const timeoutId = setTimeout(() => { setAgentInitializing(agentId, false); rejectInitDeferred( key, - new Error( - `History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s` - ) + new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`), ); }, INIT_TIMEOUT_MS); attachInitTimeout(key, timeoutId); @@ -93,19 +84,14 @@ export function useAgentInitialization({ return deferred.promise; } - client - .fetchAgentTimeline(agentId, timelineRequest) - .catch((error) => { - setAgentInitializing(agentId, false); - rejectInitDeferred( - key, - error instanceof Error ? error : new Error(String(error)) - ); - }); + client.fetchAgentTimeline(agentId, timelineRequest).catch((error) => { + setAgentInitializing(agentId, false); + rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error))); + }); return deferred.promise; }, - [client, serverId, setAgentInitializing] + [client, serverId, setAgentInitializing], ); const refreshAgent = useCallback( @@ -128,7 +114,7 @@ export function useAgentInitialization({ throw error; } }, - [client, setAgentInitializing] + [client, setAgentInitializing], ); return { ensureAgentIsInitialized, refreshAgent }; diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 5ba90855a..200474970 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -1,144 +1,142 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import type { AttachmentMetadata } from '@/attachments/types' -import { useDraftStore } from '@/stores/draft-store' +import { useCallback, useEffect, useRef, useState } from "react"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { useDraftStore } from "@/stores/draft-store"; -type ImageUpdater = - | AttachmentMetadata[] - | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]) +type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]); interface AgentInputDraft { - text: string - setText: (text: string) => void - images: AttachmentMetadata[] - setImages: (updater: ImageUpdater) => void - clear: (lifecycle: 'sent' | 'abandoned') => void - isHydrated: boolean + text: string; + setText: (text: string) => void; + images: AttachmentMetadata[]; + setImages: (updater: ImageUpdater) => void; + clear: (lifecycle: "sent" | "abandoned") => void; + isHydrated: boolean; } function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean { - return input.text.trim().length > 0 || input.images.length > 0 + return input.text.trim().length > 0 || input.images.length > 0; } function areImagesEqual(input: { - left: AttachmentMetadata[] - right: AttachmentMetadata[] + left: AttachmentMetadata[]; + right: AttachmentMetadata[]; }): boolean { if (input.left.length !== input.right.length) { - return false + return false; } return input.left.every((image, index) => { - const other = input.right[index] + const other = input.right[index]; return ( image.id === other?.id && image.mimeType === other?.mimeType && image.storageType === other?.storageType && image.storageKey === other?.storageKey - ) - }) + ); + }); } export function useAgentInputDraft(draftKey: string): AgentInputDraft { - const [text, setText] = useState('') - const [images, setImagesState] = useState([]) - const [isHydrated, setIsHydrated] = useState(false) - const draftGenerationRef = useRef(0) - const hydratedGenerationRef = useRef(0) + const [text, setText] = useState(""); + const [images, setImagesState] = useState([]); + const [isHydrated, setIsHydrated] = useState(false); + const draftGenerationRef = useRef(0); + const hydratedGenerationRef = useRef(0); const setImages = useCallback((updater: ImageUpdater) => { setImagesState((previousImages) => { - if (typeof updater === 'function') { - return updater(previousImages) + if (typeof updater === "function") { + return updater(previousImages); } - return updater - }) - }, []) + return updater; + }); + }, []); const clear = useCallback( - (lifecycle: 'sent' | 'abandoned') => { - const store = useDraftStore.getState() - store.clearDraftInput({ draftKey, lifecycle }) + (lifecycle: "sent" | "abandoned") => { + const store = useDraftStore.getState(); + store.clearDraftInput({ draftKey, lifecycle }); - const generation = store.beginDraftGeneration(draftKey) - draftGenerationRef.current = generation - hydratedGenerationRef.current = generation + const generation = store.beginDraftGeneration(draftKey); + draftGenerationRef.current = generation; + hydratedGenerationRef.current = generation; - setText('') - setImagesState([]) - setIsHydrated(true) + setText(""); + setImagesState([]); + setIsHydrated(true); }, - [draftKey] - ) + [draftKey], + ); useEffect(() => { - const store = useDraftStore.getState() - const generation = store.beginDraftGeneration(draftKey) - draftGenerationRef.current = generation - hydratedGenerationRef.current = 0 + const store = useDraftStore.getState(); + const generation = store.beginDraftGeneration(draftKey); + draftGenerationRef.current = generation; + hydratedGenerationRef.current = 0; - setText('') - setImagesState([]) - setIsHydrated(false) + setText(""); + setImagesState([]); + setIsHydrated(false); - let cancelled = false + let cancelled = false; void (async () => { - const draft = await store.hydrateDraftInput(draftKey) + const draft = await store.hydrateDraftInput(draftKey); if (cancelled) { - return + return; } if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) { - return + return; } if (draft) { - setText(draft.text) - setImagesState(draft.images) + setText(draft.text); + setImagesState(draft.images); } - hydratedGenerationRef.current = generation - setIsHydrated(true) - })() + hydratedGenerationRef.current = generation; + setIsHydrated(true); + })(); return () => { - cancelled = true - } - }, [draftKey]) + cancelled = true; + }; + }, [draftKey]); useEffect(() => { - const currentGeneration = draftGenerationRef.current + const currentGeneration = draftGenerationRef.current; if (currentGeneration <= 0) { - return + return; } - const store = useDraftStore.getState() + const store = useDraftStore.getState(); const isCurrentGeneration = store.isDraftGenerationCurrent({ draftKey, generation: currentGeneration, - }) + }); if (!isCurrentGeneration) { - return + return; } if (hydratedGenerationRef.current !== currentGeneration) { - return + return; } - const existing = store.getDraftInput(draftKey) + const existing = store.getDraftInput(draftKey); const isSameDraft = existing?.text === text && areImagesEqual({ left: existing?.images ?? [], right: images, - }) + }); if (isSameDraft) { - return + return; } if (!hasDraftContent({ text, images })) { if (existing) { - store.clearDraftInput({ draftKey, lifecycle: 'abandoned' }) + store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); } - return + return; } store.saveDraftInput({ @@ -147,8 +145,8 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { text, images, }, - }) - }, [draftKey, images, text]) + }); + }, [draftKey, images, text]); return { text, @@ -157,5 +155,5 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { setImages, clear, isHydrated, - } + }; } diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 99c688837..137985743 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -47,13 +47,7 @@ function createAgent(id: string): Agent { }; } -function createAgentWithStatus({ - id, - status, -}: { - id: string; - status: Agent["status"]; -}): Agent { +function createAgentWithStatus({ id, status }: { id: string; status: Agent["status"] }): Agent { return { ...createAgent(id), status, @@ -75,7 +69,7 @@ function createBaseInput(): AgentScreenMachineInput { } function createBaseMemory( - overrides: Partial = {} + overrides: Partial = {}, ): AgentScreenMachineMemory { return { hasRenderedReady: false, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index fcb318a4d..06fabad30 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -19,9 +19,7 @@ export interface AgentScreenMachineInput { hasHydratedHistoryBefore: boolean; } -function shouldBlockInitialAuthoritativeReadyState( - input: AgentScreenMachineInput -): boolean { +function shouldBlockInitialAuthoritativeReadyState(input: AgentScreenMachineInput): boolean { return ( !input.shouldUseOptimisticStream && !input.hasHydratedHistoryBefore && @@ -96,24 +94,19 @@ export function deriveAgentScreenViewState({ nextMemory.hadInitialSyncFailure = false; } - if ( - input.missingAgentState.kind === "error" && - !input.hasHydratedHistoryBefore - ) { + if (input.missingAgentState.kind === "error" && !input.hasHydratedHistoryBefore) { nextMemory.hadInitialSyncFailure = true; } const useOptimisticCreateFlowAgent = input.shouldUseOptimisticStream && Boolean(input.placeholderAgent) && - (!input.agent || - input.agent.status === "initializing" || - input.agent.status === "idle"); + (!input.agent || input.agent.status === "initializing" || input.agent.status === "idle"); const candidateAgent = input.agent && useOptimisticCreateFlowAgent && input.placeholderAgent ? { ...input.agent, status: input.placeholderAgent.status } - : input.agent ?? input.placeholderAgent; + : (input.agent ?? input.placeholderAgent); const shouldBlockReadyState = shouldBlockInitialAuthoritativeReadyState(input); if (input.missingAgentState.kind === "not_found") { @@ -197,8 +190,7 @@ export function deriveAgentScreenViewState({ } if (ui === "toast") { - const shouldEmitHistoryRefreshToast = - memory.activeToastLatch !== "history_refresh"; + const shouldEmitHistoryRefreshToast = memory.activeToastLatch !== "history_refresh"; nextMemory.activeToastLatch = "history_refresh"; sync = { status: "catching_up", diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index f9a6af058..f77736e85 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -27,7 +27,7 @@ export function useAggregatedAgents(options?: { const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), - () => runtime.getVersion() + () => runtime.getVersion(), ); const sessionAgents = useSessionStore( @@ -37,7 +37,7 @@ export function useAggregatedAgents(options?: { result[serverId] = session.agents; } return result; - }) + }), ); const refreshAll = useCallback(() => { @@ -47,7 +47,7 @@ export function useAggregatedAgents(options?: { const result = useMemo(() => { const allAgents: AggregatedAgent[] = []; const serverLabelById = new Map( - daemons.map((daemon) => [daemon.serverId, daemon.label] as const) + daemons.map((daemon) => [daemon.serverId, daemon.label] as const), ); // Derive agent directory from all sessions @@ -102,8 +102,7 @@ export function useAggregatedAgents(options?: { // Align list loading with the runtime directory-sync machine. const isLoading = daemons.some((daemon) => { const status = - runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? - "initial_loading"; + runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading"; return status === "initial_loading" || status === "revalidating"; }); const isInitialLoad = isLoading && !hasAnyData; diff --git a/packages/app/src/hooks/use-all-agents-list.ts b/packages/app/src/hooks/use-all-agents-list.ts index 1dda484dd..d7c24e681 100644 --- a/packages/app/src/hooks/use-all-agents-list.ts +++ b/packages/app/src/hooks/use-all-agents-list.ts @@ -6,10 +6,7 @@ import { useHostRuntimeConnectionStatus, useHostRuntimeIsDirectoryLoading, } from "@/runtime/host-runtime"; -import type { - AggregatedAgent, - AggregatedAgentsResult, -} from "@/hooks/use-aggregated-agents"; +import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents"; function toAggregatedAgent(params: { source: Agent; @@ -80,14 +77,12 @@ export function useAllAgentsList(options?: { const serverId = useMemo(() => { const value = options?.serverId; - return typeof value === "string" && value.trim().length > 0 - ? value.trim() - : null; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }, [options?.serverId]); const includeArchived = options?.includeArchived ?? false; const liveAgents = useSessionStore((state) => - serverId ? state.sessions[serverId]?.agents ?? null : null + serverId ? (state.sessions[serverId]?.agents ?? null) : null, ); const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? ""); @@ -102,8 +97,7 @@ export function useAllAgentsList(options?: { if (!serverId || !liveAgents) { return []; } - const serverLabel = - daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; + const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId; return buildAllAgentsList({ agents: liveAgents.values(), serverId, diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index 1b7792477..95b5f3ee4 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -11,7 +11,7 @@ describe("useArchiveAgent", () => { queryClient, serverId: "server-a", agentId: "agent-1", - }) + }), ).toBe(false); __private__.setAgentArchiving({ @@ -26,14 +26,14 @@ describe("useArchiveAgent", () => { queryClient, serverId: "server-a", agentId: "agent-1", - }) + }), ).toBe(true); expect( __private__.isAgentArchiving({ queryClient, serverId: "server-a", agentId: "agent-2", - }) + }), ).toBe(false); __private__.setAgentArchiving({ @@ -48,16 +48,13 @@ describe("useArchiveAgent", () => { queryClient, serverId: "server-a", agentId: "agent-1", - }) + }), ).toBe(false); }); it("removes an archived agent from cached list payloads", () => { const payload = { - entries: [ - { agent: { id: "agent-1" } }, - { agent: { id: "agent-2" } }, - ], + entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], pageInfo: { hasMore: false }, }; diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 5a665366e..1f4798bab 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -34,11 +34,7 @@ function toArchiveKey(input: ArchiveAgentInput): string { } function readPendingState(queryClient: QueryClient): ArchiveAgentPendingState { - return ( - queryClient.getQueryData( - ARCHIVE_AGENT_PENDING_QUERY_KEY - ) ?? {} - ); + return queryClient.getQueryData(ARCHIVE_AGENT_PENDING_QUERY_KEY) ?? {}; } function setAgentArchiving(input: SetAgentArchivingInput): void { @@ -65,7 +61,7 @@ function setAgentArchiving(input: SetAgentArchivingInput): void { const next = { ...state }; delete next[key]; return next; - } + }, ); } @@ -79,7 +75,7 @@ function isAgentArchiving(input: IsAgentArchivingInput): boolean { function removeAgentFromListPayload( payload: T, - agentId: string + agentId: string, ): T { if (!payload || !Array.isArray(payload.entries) || !agentId) { return payload; @@ -94,10 +90,7 @@ function removeAgentFromListPayload( } as T; } -function removeAgentFromCachedLists( - queryClient: QueryClient, - input: ArchiveAgentInput -): void { +function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgentInput): void { const agentId = input.agentId.trim(); if (!agentId) { return; @@ -105,11 +98,11 @@ function removeAgentFromCachedLists( queryClient.setQueryData( ["sidebarAgentsList", input.serverId], - (current) => removeAgentFromListPayload(current, agentId) + (current) => removeAgentFromListPayload(current, agentId), ); queryClient.setQueryData( ["allAgents", input.serverId], - (current) => removeAgentFromListPayload(current, agentId) + (current) => removeAgentFromListPayload(current, agentId), ); } @@ -200,7 +193,7 @@ export function useArchiveAgent() { async (input: ArchiveAgentInput): Promise => { await archiveMutateAsync(input); }, - [archiveMutateAsync] + [archiveMutateAsync], ); const isArchivingAgent = useCallback( @@ -211,7 +204,7 @@ export function useArchiveAgent() { } return Boolean((pendingQuery.data ?? {})[key]); }, - [pendingQuery.data] + [pendingQuery.data], ); return { diff --git a/packages/app/src/hooks/use-audio-recorder.native.ts b/packages/app/src/hooks/use-audio-recorder.native.ts index 17201edf1..459c56335 100644 --- a/packages/app/src/hooks/use-audio-recorder.native.ts +++ b/packages/app/src/hooks/use-audio-recorder.native.ts @@ -1,7 +1,14 @@ -import { useAudioRecorder as useExpoAudioRecorder, useAudioRecorderState, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio'; -import { Paths, File, Directory, FileInfo } from 'expo-file-system'; -import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { AttemptCancelledError, AttemptGuard } from '@/utils/attempt-guard'; +import { + useAudioRecorder as useExpoAudioRecorder, + useAudioRecorderState, + RecordingOptions, + RecordingPresets, + requestRecordingPermissionsAsync, + setAudioModeAsync, +} from "expo-audio"; +import { Paths, File, Directory, FileInfo } from "expo-file-system"; +import { useState, useEffect, useRef, useMemo, useCallback } from "react"; +import { AttemptCancelledError, AttemptGuard } from "@/utils/attempt-guard"; export interface AudioCaptureConfig { sampleRate?: number; @@ -18,8 +25,7 @@ export interface AudioCaptureConfig { */ async function getActualRecordingUri(createdAt: Date): Promise { try { - - const audioDir = new Directory(Paths.cache, 'Audio'); + const audioDir = new Directory(Paths.cache, "Audio"); if (!audioDir.exists) { return null; @@ -32,11 +38,11 @@ async function getActualRecordingUri(createdAt: Date): Promise { } const validFiles = files - .map(file => { + .map((file) => { const info = file.info(); return info; }) - .filter(f => f.size && f.size > 0); + .filter((f) => f.size && f.size > 0); if (validFiles.length === 0) { return null; @@ -61,7 +67,7 @@ async function getActualRecordingUri(createdAt: Date): Promise { return null; } catch (e) { - console.error('[AudioRecorder] Error finding actual recording file:', e); + console.error("[AudioRecorder] Error finding actual recording file:", e); return null; } } @@ -79,7 +85,7 @@ async function uriToBlob(uri: string): Promise { // React Native doesn't support creating Blobs from binary data // Create a Blob-like object that has the methods we need const blobLike = { - type: 'audio/m4a', + type: "audio/m4a", size: size, arrayBuffer: async () => { // Convert base64 to ArrayBuffer @@ -114,33 +120,36 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }, [config]); // Create stable recording options - only recreate if actual config values change - const recordingOptions: RecordingOptions = useMemo(() => ({ - ...RecordingPresets.HIGH_QUALITY, - sampleRate: config?.sampleRate || 16000, - numberOfChannels: config?.numberOfChannels || 1, - bitRate: config?.bitRate || 128000, - extension: '.m4a', - isMeteringEnabled: !!config?.onAudioLevel, // Enable metering if callback provided - android: { - extension: '.m4a', - outputFormat: 'mpeg4', - audioEncoder: 'aac', + const recordingOptions: RecordingOptions = useMemo( + () => ({ + ...RecordingPresets.HIGH_QUALITY, sampleRate: config?.sampleRate || 16000, - audioSource: 'voice_communication', // Enables echo cancellation, noise suppression, auto gain control - }, - ios: { - extension: '.m4a', - audioQuality: 127, // High quality - sampleRate: config?.sampleRate || 16000, - linearPCMBitDepth: 16, - linearPCMIsBigEndian: false, - linearPCMIsFloat: false, - }, - web: { - mimeType: 'audio/webm;codecs=opus', - bitsPerSecond: config?.bitRate || 128000, - }, - }), [config?.sampleRate, config?.numberOfChannels, config?.bitRate, config?.onAudioLevel]); + numberOfChannels: config?.numberOfChannels || 1, + bitRate: config?.bitRate || 128000, + extension: ".m4a", + isMeteringEnabled: !!config?.onAudioLevel, // Enable metering if callback provided + android: { + extension: ".m4a", + outputFormat: "mpeg4", + audioEncoder: "aac", + sampleRate: config?.sampleRate || 16000, + audioSource: "voice_communication", // Enables echo cancellation, noise suppression, auto gain control + }, + ios: { + extension: ".m4a", + audioQuality: 127, // High quality + sampleRate: config?.sampleRate || 16000, + linearPCMBitDepth: 16, + linearPCMIsBigEndian: false, + linearPCMIsFloat: false, + }, + web: { + mimeType: "audio/webm;codecs=opus", + bitsPerSecond: config?.bitRate || 128000, + }, + }), + [config?.sampleRate, config?.numberOfChannels, config?.bitRate, config?.onAudioLevel], + ); const audioRecorder = useExpoAudioRecorder(recordingOptions); const recorderState = useAudioRecorderState(audioRecorder, 100); @@ -176,7 +185,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { // Use expo's isRecording as single source of truth if (recorder.isRecording) { - throw new Error('Already recording'); + throw new Error("Already recording"); } try { @@ -188,7 +197,9 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { attemptGuardRef.current.assertCurrent(attemptId); if (!permissionResponse.granted) { - throw new Error('Microphone permission denied. Please enable microphone access in your device settings.'); + throw new Error( + "Microphone permission denied. Please enable microphone access in your device settings.", + ); } // Configure audio mode for recording @@ -208,14 +219,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { await recorder.record(); attemptGuardRef.current.assertCurrent(attemptId); - } catch (error: any) { setRecordingStartTime(null); if (error instanceof AttemptCancelledError) { return; } - if (error?.message !== 'Recording cancelled') { - console.error('[AudioRecorder] Failed to start recording:', error); + if (error?.message !== "Recording cancelled") { + console.error("[AudioRecorder] Failed to start recording:", error); } throw new Error(`Failed to start audio recording: ${error.message}`); } @@ -236,24 +246,26 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { if (recorder.isRecording) { await recorder.stop(); } else { - console.warn('[AudioRecorder] Recorder already stopped before stop() call, continuing cleanup.'); + console.warn( + "[AudioRecorder] Recorder already stopped before stop() call, continuing cleanup.", + ); } // Get URI from recorder let uri = recorder.uri; // Workaround for Expo SDK 54 Android bug - find actual recording file - if (recordingStartTime && (!uri || uri === '')) { + if (recordingStartTime && (!uri || uri === "")) { const actualUri = await getActualRecordingUri(recordingStartTime); if (actualUri) { uri = actualUri; } } - if (!uri || uri === '') { + if (!uri || uri === "") { // Cancellation / early stop: return an empty blob, but guarantee cleanup. setRecordingStartTime(null); - return new Blob([], { type: 'audio/m4a' }); + return new Blob([], { type: "audio/m4a" }); } // Get file info @@ -262,7 +274,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { if (!exists) { setRecordingStartTime(null); - return new Blob([], { type: 'audio/m4a' }); + return new Blob([], { type: "audio/m4a" }); } // Convert URI to Blob @@ -280,7 +292,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { return await stopPromise; } catch (error: any) { setRecordingStartTime(null); - console.error('[AudioRecorder] Failed to stop recording:', error); + console.error("[AudioRecorder] Failed to stop recording:", error); throw new Error(`Failed to stop audio recording: ${error.message}`); } finally { startStopMutexRef.current = null; @@ -290,7 +302,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { const getSupportedMimeType = useCallback((): string | null => { // On native platforms, expo-audio uses m4a/AAC // On web, it can use webm/opus - return 'audio/m4a'; + return "audio/m4a"; }, []); const isRecording = useCallback(() => { @@ -298,10 +310,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }, [recorderState.isRecording]); // Return stable object using useMemo - return useMemo(() => ({ - start, - stop, - isRecording, - getSupportedMimeType, - }), [start, stop, isRecording, getSupportedMimeType]); + return useMemo( + () => ({ + start, + stop, + isRecording, + getSupportedMimeType, + }), + [start, stop, isRecording, getSupportedMimeType], + ); } diff --git a/packages/app/src/hooks/use-audio-recorder.web.ts b/packages/app/src/hooks/use-audio-recorder.web.ts index 9c8ab3f86..c2b7e5047 100644 --- a/packages/app/src/hooks/use-audio-recorder.web.ts +++ b/packages/app/src/hooks/use-audio-recorder.web.ts @@ -46,7 +46,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { } const detected = MIME_TYPE_CANDIDATES.find((candidate) => - window.MediaRecorder.isTypeSupported(candidate) + window.MediaRecorder.isTypeSupported(candidate), ); supportedMimeTypeRef.current = detected ?? null; @@ -165,13 +165,13 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { if (!secureContext && !isDesktopApp) { throw new Error( - `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}` + `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`, ); } if (!secureContext && isDesktopApp) { console.warn( "[AudioRecorder][Web] Insecure context reported under Desktop; attempting getUserMedia anyway", - { currentOrigin } + { currentOrigin }, ); } @@ -335,9 +335,10 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { }; }, [hardReset]); - const getSupportedMimeType = useCallback(() => detectSupportedMimeType(), [ - detectSupportedMimeType, - ]); + const getSupportedMimeType = useCallback( + () => detectSupportedMimeType(), + [detectSupportedMimeType], + ); return { start, diff --git a/packages/app/src/hooks/use-autocomplete.ts b/packages/app/src/hooks/use-autocomplete.ts index 6a566870a..2a524b91c 100644 --- a/packages/app/src/hooks/use-autocomplete.ts +++ b/packages/app/src/hooks/use-autocomplete.ts @@ -20,7 +20,7 @@ interface UseAutocompleteResult { } export function useAutocomplete( - input: UseAutocompleteInput + input: UseAutocompleteInput, ): UseAutocompleteResult { const [selectedIndex, setSelectedIndex] = useState(-1); const previousQueryRef = useRef(""); @@ -42,7 +42,7 @@ export function useAutocomplete( const fallbackIndex = getAutocompleteFallbackIndex( input.options.length, - input.optionsPosition + input.optionsPosition, ); if (queryChanged) { @@ -68,7 +68,7 @@ export function useAutocomplete( currentIndex: current, itemCount: input.options.length, key: "ArrowUp", - }) + }), ); return true; } @@ -80,7 +80,7 @@ export function useAutocomplete( currentIndex: current, itemCount: input.options.length, key: "ArrowDown", - }) + }), ); return true; } @@ -89,7 +89,7 @@ export function useAutocomplete( event.preventDefault(); const fallbackIndex = getAutocompleteFallbackIndex( input.options.length, - input.optionsPosition + input.optionsPosition, ); const resolvedIndex = selectedIndex >= 0 && selectedIndex < input.options.length @@ -110,7 +110,7 @@ export function useAutocomplete( return false; }, - [input, selectedIndex] + [input, selectedIndex], ); return { diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index 4e088e1db..874809dca 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -12,7 +12,7 @@ function checkoutDiffQueryKey( serverId: string, cwd: string, mode: "uncommitted" | "base", - baseRef?: string + baseRef?: string, ) { return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const; } @@ -25,10 +25,7 @@ interface UseCheckoutDiffQueryOptions { enabled?: boolean; } -type CheckoutDiffQueryPayload = Omit< - SubscribeCheckoutDiffResponse["payload"], - "subscriptionId" ->; +type CheckoutDiffQueryPayload = Omit; export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number]; export type DiffHunk = ParsedDiffFile["hunks"][number]; @@ -43,9 +40,7 @@ function normalizeCheckoutDiffCompare(compare: { return { mode: "uncommitted" }; } const trimmedBaseRef = compare.baseRef?.trim(); - return trimmedBaseRef - ? { mode: "base", baseRef: trimmedBaseRef } - : { mode: "base" }; + return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" }; } export function useCheckoutDiffQuery({ @@ -58,8 +53,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 = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const explorerTab = usePanelStore((state) => state.explorerTab); @@ -67,13 +61,13 @@ export function useCheckoutDiffQuery({ const hookInstanceId = useId(); const normalizedCompare = useMemo( () => normalizeCheckoutDiffCompare({ mode, baseRef }), - [mode, baseRef] + [mode, baseRef], ); const compareMode = normalizedCompare.mode; const compareBaseRef = normalizedCompare.baseRef; const queryKey = useMemo( () => checkoutDiffQueryKey(serverId, cwd, mode, baseRef), - [serverId, cwd, mode, baseRef] + [serverId, cwd, mode, baseRef], ); const query = useQuery({ @@ -142,7 +136,7 @@ export function useCheckoutDiffQuery({ error: message.payload.error, requestId: message.payload.requestId, }); - } + }, ); void client @@ -152,7 +146,7 @@ export function useCheckoutDiffQuery({ mode: compareMode, baseRef: compareBaseRef, }, - { subscriptionId } + { subscriptionId }, ) .then((payload) => { if (cancelled) { diff --git a/packages/app/src/hooks/use-checkout-status-query.test.ts b/packages/app/src/hooks/use-checkout-status-query.test.ts index 403c49499..8911561e8 100644 --- a/packages/app/src/hooks/use-checkout-status-query.test.ts +++ b/packages/app/src/hooks/use-checkout-status-query.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { checkoutStatusRevalidationKey, nextCheckoutStatusRefetchDecision } from "./checkout-status-revalidation"; +import { + checkoutStatusRevalidationKey, + nextCheckoutStatusRefetchDecision, +} from "./checkout-status-revalidation"; describe("useCheckoutStatusQuery", () => { describe("checkoutStatusRevalidationKey", () => { @@ -10,7 +13,7 @@ describe("useCheckoutStatusQuery", () => { cwd: "/path/to/project", isOpen: false, explorerTab: "changes", - }) + }), ).toBeNull(); }); @@ -21,7 +24,7 @@ describe("useCheckoutStatusQuery", () => { cwd: "/path/to/project", isOpen: true, explorerTab: "files", - }) + }), ).toBeNull(); }); @@ -32,7 +35,7 @@ describe("useCheckoutStatusQuery", () => { cwd: "/path/to/project", isOpen: true, explorerTab: "changes", - }) + }), ).toBe("daemon-1:/path/to/project"); }); }); @@ -63,9 +66,7 @@ describe("useCheckoutStatusQuery", () => { }); it("refetches again when cwd changes while active", () => { - expect( - nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b") - ).toEqual({ + expect(nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")).toEqual({ nextSeenKey: "daemon-1:/path/b", shouldRefetch: true, }); diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index f1046c89e..3aed5b223 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -24,7 +24,7 @@ export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; function fetchCheckoutStatus( client: { getCheckoutStatus: (cwd: string) => Promise }, - cwd: string + cwd: string, ): Promise { return client.getCheckoutStatus(cwd); } @@ -32,8 +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 = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); const explorerTab = usePanelStore((state) => state.explorerTab); @@ -62,11 +61,14 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery // Revalidate when sidebar is open with "changes" tab active. const revalidationKey = useMemo( () => checkoutStatusRevalidationKey({ serverId, cwd, isOpen, explorerTab }), - [serverId, cwd, isOpen, explorerTab] + [serverId, cwd, isOpen, explorerTab], ); const lastRevalidationKey = useRef(null); useEffect(() => { - const decision = nextCheckoutStatusRefetchDecision(lastRevalidationKey.current, revalidationKey); + const decision = nextCheckoutStatusRefetchDecision( + lastRevalidationKey.current, + revalidationKey, + ); lastRevalidationKey.current = decision.nextSeenKey; if (!decision.shouldRefetch) return; void query.refetch(); diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index d10d43e07..6347d08a1 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -26,7 +26,7 @@ export function useClientActivity({ const appVisibleRef = useRef(AppState.currentState === "active"); const appVisibilityChangedAtRef = useRef(new Date()); const backgroundedAtMsRef = useRef( - AppState.currentState === "active" ? null : Date.now() + AppState.currentState === "active" ? null : Date.now(), ); const heartbeatIntervalRef = useRef | null>(null); const prevFocusedAgentIdRef = useRef(focusedAgentId); @@ -70,7 +70,7 @@ export function useClientActivity({ } recordUserActivity(); }, - [onAppResumed, recordUserActivity] + [onAppResumed, recordUserActivity], ); const maybeSendImmediateHeartbeat = useCallback(() => { diff --git a/packages/app/src/hooks/use-color-scheme.ts b/packages/app/src/hooks/use-color-scheme.ts index 17e3c63e1..93a8fde95 100644 --- a/packages/app/src/hooks/use-color-scheme.ts +++ b/packages/app/src/hooks/use-color-scheme.ts @@ -1 +1 @@ -export { useColorScheme } from 'react-native'; +export { useColorScheme } from "react-native"; diff --git a/packages/app/src/hooks/use-color-scheme.web.ts b/packages/app/src/hooks/use-color-scheme.web.ts index 7eb1c1b70..66cccac5e 100644 --- a/packages/app/src/hooks/use-color-scheme.web.ts +++ b/packages/app/src/hooks/use-color-scheme.web.ts @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { useColorScheme as useRNColorScheme } from 'react-native'; +import { useEffect, useState } from "react"; +import { useColorScheme as useRNColorScheme } from "react-native"; /** * To support static rendering, this value needs to be re-calculated on the client side for web @@ -17,5 +17,5 @@ export function useColorScheme() { return colorScheme; } - return 'light'; + return "light"; } diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 49d97297d..daabe6a35 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -11,10 +11,7 @@ import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; -import { - buildHostSettingsRoute, - parseServerIdFromPathname, -} from "@/utils/host-routes"; +import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { focusWithRetries } from "@/utils/web-focus"; @@ -74,10 +71,7 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ }, ]; -function matchesActionQuery( - query: string, - action: CommandCenterActionDefinition -): boolean { +function matchesActionQuery(query: string, action: CommandCenterActionDefinition): boolean { const normalized = query.trim().toLowerCase(); if (!normalized) return true; if (action.title.toLowerCase().includes(normalized)) { @@ -157,7 +151,7 @@ export function useCommandCenter() { return EMPTY_ACTION_ITEMS; } return COMMAND_CENTER_ACTIONS.filter((action) => - matchesActionQuery(query, action) + matchesActionQuery(query, action), ).map((action) => ({ kind: "action", id: action.id, @@ -206,24 +200,27 @@ export function useCommandCenter() { }); router.navigate(route as any); }, - [setOpen] + [setOpen], ); const openProjectPicker = useOpenProjectPicker(activeServerId); - const handleSelectAction = useCallback((action: CommandCenterActionItem) => { - clearCommandCenterFocusRestoreElement(); - setOpen(false); - if (action.id === "new-agent") { - void openProjectPicker(); - return; - } - if (!action.route) { - return; - } - didNavigateRef.current = true; - router.push(action.route); - }, [openProjectPicker, setOpen]); + const handleSelectAction = useCallback( + (action: CommandCenterActionItem) => { + clearCommandCenterFocusRestoreElement(); + setOpen(false); + if (action.id === "new-agent") { + void openProjectPicker(); + return; + } + if (!action.route) { + return; + } + didNavigateRef.current = true; + router.push(action.route); + }, + [openProjectPicker, setOpen], + ); const handleSelectItem = useCallback( (item: CommandCenterItem) => { @@ -233,7 +230,7 @@ export function useCommandCenter() { } handleSelectAgent(item.agent); }, - [handleSelectAction, handleSelectAgent] + [handleSelectAction, handleSelectAgent], ); useEffect(() => { @@ -263,9 +260,7 @@ export function useCommandCenter() { if (prevOpen && !didNavigateRef.current) { const el = takeCommandCenterFocusRestoreElement(); const isFocused = () => - Boolean(el) && - typeof document !== "undefined" && - document.activeElement === el; + Boolean(el) && typeof document !== "undefined" && document.activeElement === el; const cancel = focusWithRetries({ focus: () => el?.focus(), @@ -304,12 +299,7 @@ export function useCommandCenter() { const handler = (event: KeyboardEvent) => { const currentItems = itemsRef.current; const key = event.key; - if ( - key !== "ArrowDown" && - key !== "ArrowUp" && - key !== "Enter" && - key !== "Escape" - ) { + if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { return; } @@ -322,10 +312,7 @@ export function useCommandCenter() { if (key === "Enter") { if (currentItems.length === 0) return; event.preventDefault(); - const index = Math.max( - 0, - Math.min(activeIndexRef.current, currentItems.length - 1) - ); + const index = Math.max(0, Math.min(activeIndexRef.current, currentItems.length - 1)); handleSelectItemRef.current(currentItems[index]!); return; } diff --git a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx index 967af3ab8..3c1d1d772 100644 --- a/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx +++ b/packages/app/src/hooks/use-delayed-history-refresh-toast.tsx @@ -46,12 +46,7 @@ export function useDelayedHistoryRefreshToast({ return; } showToastRef.current("Refreshing", { - icon: ( - - ), + icon: , durationMs: HISTORY_REFRESH_TOAST_DURATION_MS, testID: "agent-history-refresh-toast", }); diff --git a/packages/app/src/hooks/use-dictation-audio-source.d.ts b/packages/app/src/hooks/use-dictation-audio-source.d.ts index 45d7043ea..4fbfb4f2a 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.d.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.d.ts @@ -1,2 +1 @@ export * from "./use-dictation-audio-source.native"; - diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts index 446030944..50642fa15 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.native.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts @@ -4,7 +4,10 @@ import { useState } from "react"; import { createAudioEngine } from "@/voice/audio-engine"; -import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; +import type { + DictationAudioSource, + DictationAudioSourceConfig, +} from "./use-dictation-audio-source.types"; export function useDictationAudioSource(config: DictationAudioSourceConfig): DictationAudioSource { const onPcmSegmentRef = useRef(config.onPcmSegment); diff --git a/packages/app/src/hooks/use-dictation-audio-source.types.ts b/packages/app/src/hooks/use-dictation-audio-source.types.ts index fd34966b6..08d4e124b 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.types.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.types.ts @@ -8,4 +8,3 @@ export type DictationAudioSource = { stop: () => Promise; volume: number; }; - diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts index c17707cb0..51800086f 100644 --- a/packages/app/src/hooks/use-dictation-audio-source.web.ts +++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts @@ -2,9 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { parsePcm16Wav } from "@/utils/pcm16-wav"; import { isDesktop } from "@/desktop/host"; -import type { DictationAudioSource, DictationAudioSourceConfig } from "./use-dictation-audio-source.types"; +import type { + DictationAudioSource, + DictationAudioSourceConfig, +} from "./use-dictation-audio-source.types"; -const getAudioContextCtor = (): (typeof AudioContext) | null => { +const getAudioContextCtor = (): typeof AudioContext | null => { if (typeof window === "undefined") { return null; } @@ -19,7 +22,11 @@ const floatToInt16 = (sample: number): number => { return clamped < 0 ? Math.round(clamped * 0x8000) : Math.round(clamped * 0x7fff); }; -const resampleToPcm16 = (input: Float32Array, inputRate: number, outputRate: number): Int16Array => { +const resampleToPcm16 = ( + input: Float32Array, + inputRate: number, + outputRate: number, +): Int16Array => { if (input.length === 0) { return new Int16Array(0); } @@ -122,15 +129,18 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic }, }); - const decodeAudioData = useCallback(async (context: AudioContext, buffer: ArrayBuffer): Promise => { - const maybePromise = context.decodeAudioData(buffer); - if (maybePromise && typeof (maybePromise as Promise).then === "function") { - return maybePromise as Promise; - } - return await new Promise((resolve, reject) => { - context.decodeAudioData(buffer, resolve, reject); - }); - }, []); + const decodeAudioData = useCallback( + async (context: AudioContext, buffer: ArrayBuffer): Promise => { + const maybePromise = context.decodeAudioData(buffer); + if (maybePromise && typeof (maybePromise as Promise).then === "function") { + return maybePromise as Promise; + } + return await new Promise((resolve, reject) => { + context.decodeAudioData(buffer, resolve, reject); + }); + }, + [], + ); const emitPcmSegments = useCallback((pcm: Int16Array) => { const outputRate = 16000; @@ -164,12 +174,14 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic throw new Error("Microphone capture is not supported in this environment"); } if (!secureContext && !isDesktopApp) { - throw new Error(`Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`); + throw new Error( + `Microphone access requires HTTPS or localhost. Current origin: ${currentOrigin}`, + ); } if (!secureContext && isDesktopApp) { console.warn( "[DictationAudio][Web] Insecure context reported under Desktop; attempting getUserMedia anyway", - { currentOrigin } + { currentOrigin }, ); } @@ -253,7 +265,9 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic throw new Error("MediaRecorder unavailable"); } - const recorder = new RecorderCtor(stream, { mimeType: "audio/webm;codecs=opus" } as MediaRecorderOptions) as MediaRecorder; + const recorder = new RecorderCtor(stream, { + mimeType: "audio/webm;codecs=opus", + } as MediaRecorderOptions) as MediaRecorder; const recorderRefs: RecorderRefs = { recorder, @@ -274,7 +288,10 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic }; recorder.addEventListener("stop", () => { try { - const blob = recorderRefs.audioChunks.length > 0 ? new Blob(recorderRefs.audioChunks, { type: recorder.mimeType }) : new Blob([], { type: recorder.mimeType }); + const blob = + recorderRefs.audioChunks.length > 0 + ? new Blob(recorderRefs.audioChunks, { type: recorder.mimeType }) + : new Blob([], { type: recorder.mimeType }); recorderRefs.stoppedResolve?.(blob); } catch (err) { recorderRefs.stoppedReject?.(err); @@ -310,8 +327,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic refs.current.started = false; setVolume(0); - const { processor, source, gain, context, stream, pending, mode, recorder } = - refs.current; + const { processor, source, gain, context, stream, pending, mode, recorder } = refs.current; if (processor) { try { @@ -434,6 +450,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic stop, volume, }), - [start, stop, volume] + [start, stop, volume], ); } diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts index 0e5d50f06..eff44f045 100644 --- a/packages/app/src/hooks/use-dictation.ts +++ b/packages/app/src/hooks/use-dictation.ts @@ -126,7 +126,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setError(normalized.message); onErrorRef.current?.(normalized); }, - [setError] + [setError], ); const clearStreamingState = useCallback(() => { @@ -135,20 +135,14 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setPartialTranscript(""); }, []); - const startNewStream = useCallback( - async (reason: string) => { - await senderRef.current?.restartStream(reason); - }, - [] - ); + const startNewStream = useCallback(async (reason: string) => { + await senderRef.current?.restartStream(reason); + }, []); - const ensureFinalTranscript = useCallback( - async (finalSeq: number): Promise => { - const result = await senderRef.current!.finish(finalSeq); - return result.text; - }, - [] - ); + const ensureFinalTranscript = useCallback(async (finalSeq: number): Promise => { + const result = await senderRef.current!.finish(finalSeq); + return result.text; + }, []); useEffect(() => { if (!client) { @@ -209,9 +203,8 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { setDuration(0); setStatus("idle"); - const transcriptText = ( - text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim() - ); + const transcriptText = + text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim(); clearStreamingState(); if (!transcriptText) { @@ -219,7 +212,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { } onTranscriptRef.current?.(transcriptText, { requestId }); }, - [clearStreamingState] + [clearStreamingState], ); const handleDictationFailure = useCallback( @@ -240,11 +233,15 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { reportError(normalized, "Failed to complete dictation"); }, - [reportError] + [reportError], ); const startDictation = useCallback(async () => { - if (actionGateRef.current.starting || actionGateRef.current.confirming || actionGateRef.current.cancelling) { + if ( + actionGateRef.current.starting || + actionGateRef.current.confirming || + actionGateRef.current.cancelling + ) { return; } if (isRecordingRef.current || isProcessingRef.current) { @@ -438,10 +435,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult { }, [cancelDictation]); const visibilityRef = useRef( - typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null + typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null, ); useEffect(() => { - const nextVisible = typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null; + const nextVisible = + typeof autoStopWhenHidden?.isVisible === "boolean" ? autoStopWhenHidden.isVisible : null; const prevVisible = visibilityRef.current; visibilityRef.current = nextVisible; diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts index 3ce5aa40a..98d55b74f 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -1,77 +1,84 @@ -import { useCallback, useMemo, useReducer } from 'react' -import { useCreateFlowStore } from '@/stores/create-flow-store' -import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from '@/types/stream' +import { useCallback, useMemo, useReducer } from "react"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { + generateMessageId, + type StreamItem, + type UserMessageImageAttachment, +} from "@/types/stream"; -const EMPTY_STREAM_ITEMS: StreamItem[] = [] +const EMPTY_STREAM_ITEMS: StreamItem[] = []; type CreateAttempt = { - clientMessageId: string - text: string - timestamp: Date - images?: UserMessageImageAttachment[] -} + clientMessageId: string; + text: string; + timestamp: Date; + images?: UserMessageImageAttachment[]; +}; type DraftAgentMachineState = - | { tag: 'draft'; errorMessage: string } - | { tag: 'creating'; attempt: CreateAttempt } + | { tag: "draft"; errorMessage: string } + | { tag: "creating"; attempt: CreateAttempt }; type DraftAgentMachineEvent = - | { type: 'DRAFT_SET_ERROR'; message: string } - | { type: 'SUBMIT'; attempt: CreateAttempt } - | { type: 'CREATE_FAILED'; message: string } + | { type: "DRAFT_SET_ERROR"; message: string } + | { type: "SUBMIT"; attempt: CreateAttempt } + | { type: "CREATE_FAILED"; message: string }; function assertNever(value: never): never { - throw new Error(`Unhandled state: ${JSON.stringify(value)}`) + throw new Error(`Unhandled state: ${JSON.stringify(value)}`); } -function reducer(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState { +function reducer( + state: DraftAgentMachineState, + event: DraftAgentMachineEvent, +): DraftAgentMachineState { switch (event.type) { - case 'DRAFT_SET_ERROR': { - if (state.tag !== 'draft') { - return state + case "DRAFT_SET_ERROR": { + if (state.tag !== "draft") { + return state; } - return { ...state, errorMessage: event.message } + return { ...state, errorMessage: event.message }; } - case 'SUBMIT': { - return { tag: 'creating', attempt: event.attempt } + case "SUBMIT": { + return { tag: "creating", attempt: event.attempt }; } - case 'CREATE_FAILED': { - if (state.tag !== 'creating') { - return state + case "CREATE_FAILED": { + if (state.tag !== "creating") { + return state; } - return { tag: 'draft', errorMessage: event.message } + return { tag: "draft", errorMessage: event.message }; } default: - return assertNever(event) + return assertNever(event); } } type CreateRequestResult = { - agentId: string | null - result: TCreateResult -} + agentId: string | null; + result: TCreateResult; +}; type SubmitContext = { - text: string - images?: UserMessageImageAttachment[] -} + text: string; + images?: UserMessageImageAttachment[]; +}; type CreateRequestContext = { - attempt: CreateAttempt - text: string - images?: UserMessageImageAttachment[] -} + attempt: CreateAttempt; + text: string; + images?: UserMessageImageAttachment[]; +}; interface UseDraftAgentCreateFlowOptions { - draftId: string - getPendingServerId: () => string | null - validateBeforeSubmit?: (ctx: SubmitContext) => string | null - onBeforeSubmit?: (ctx: CreateRequestContext) => void - onCreateStart?: () => void - createRequest: (ctx: CreateRequestContext) => Promise> - buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent - onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise | void - onCreateError?: (error: Error) => void + draftId: string; + getPendingServerId: () => string | null; + validateBeforeSubmit?: (ctx: SubmitContext) => string | null; + onBeforeSubmit?: (ctx: CreateRequestContext) => void; + onCreateStart?: () => void; + createRequest: (ctx: CreateRequestContext) => Promise>; + buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent; + onCreateSuccess: (ctx: { result: TCreateResult; attempt: CreateAttempt }) => Promise | void; + onCreateError?: (error: Error) => void; } export function useDraftAgentCreateFlow({ @@ -86,26 +93,26 @@ export function useDraftAgentCreateFlow({ onCreateError, }: UseDraftAgentCreateFlowOptions) { const [machine, dispatch] = useReducer(reducer, { - tag: 'draft', - errorMessage: '', - } as DraftAgentMachineState) + tag: "draft", + errorMessage: "", + } as DraftAgentMachineState); - const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending) - const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId) - const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle) - const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear) + const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending); + const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId); + const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); + const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear); - const formErrorMessage = machine.tag === 'draft' ? machine.errorMessage : '' - const isSubmitting = machine.tag === 'creating' + const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : ""; + const isSubmitting = machine.tag === "creating"; const optimisticStreamItems = useMemo(() => { - if (machine.tag !== 'creating') { - return EMPTY_STREAM_ITEMS + if (machine.tag !== "creating") { + return EMPTY_STREAM_ITEMS; } return [ { - kind: 'user_message', + kind: "user_message", id: machine.attempt.clientMessageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, @@ -113,43 +120,43 @@ export function useDraftAgentCreateFlow({ ? { images: machine.attempt.images } : {}), }, - ] - }, [machine]) + ]; + }, [machine]); const draftAgent = useMemo(() => { - if (machine.tag !== 'creating') { - return null + if (machine.tag !== "creating") { + return null; } - return buildDraftAgent(machine.attempt) - }, [buildDraftAgent, machine]) + return buildDraftAgent(machine.attempt); + }, [buildDraftAgent, machine]); const handleCreateFromInput = useCallback( async ({ text, images }: SubmitContext) => { if (isSubmitting) { - throw new Error('Already loading') + throw new Error("Already loading"); } - dispatch({ type: 'DRAFT_SET_ERROR', message: '' }) + dispatch({ type: "DRAFT_SET_ERROR", message: "" }); - const trimmedPrompt = text.trim() + const trimmedPrompt = text.trim(); if (!trimmedPrompt) { - const error = new Error('Initial prompt is required') - dispatch({ type: 'DRAFT_SET_ERROR', message: error.message }) - throw error + const error = new Error("Initial prompt is required"); + dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); + throw error; } - const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images }) + const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images }); if (validationError) { - const error = new Error(validationError) - dispatch({ type: 'DRAFT_SET_ERROR', message: validationError }) - throw error + const error = new Error(validationError); + dispatch({ type: "DRAFT_SET_ERROR", message: validationError }); + throw error; } - const pendingServerId = getPendingServerId() + const pendingServerId = getPendingServerId(); if (!pendingServerId) { - const error = new Error('No host selected') - dispatch({ type: 'DRAFT_SET_ERROR', message: error.message }) - throw error + const error = new Error("No host selected"); + dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); + throw error; } const attempt: CreateAttempt = { @@ -157,7 +164,7 @@ export function useDraftAgentCreateFlow({ text: trimmedPrompt, timestamp: new Date(), ...(images && images.length > 0 ? { images } : {}), - } + }; setPendingCreateAttempt({ draftId, @@ -167,31 +174,31 @@ export function useDraftAgentCreateFlow({ text: attempt.text, timestamp: attempt.timestamp.getTime(), ...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}), - }) + }); - onBeforeSubmit?.({ attempt, text: trimmedPrompt, images }) - dispatch({ type: 'SUBMIT', attempt }) - onCreateStart?.() + onBeforeSubmit?.({ attempt, text: trimmedPrompt, images }); + dispatch({ type: "SUBMIT", attempt }); + onCreateStart?.(); try { const createResult = await createRequest({ attempt, text: trimmedPrompt, images, - }) + }); if (createResult.agentId) { - updatePendingAgentId({ draftId, agentId: createResult.agentId }) + updatePendingAgentId({ draftId, agentId: createResult.agentId }); } - await onCreateSuccess({ result: createResult.result, attempt }) + await onCreateSuccess({ result: createResult.result, attempt }); } catch (error) { - const resolved = error instanceof Error ? error : new Error('Failed to create agent') - dispatch({ type: 'CREATE_FAILED', message: resolved.message }) - markPendingCreateLifecycle({ draftId, lifecycle: 'abandoned' }) - clearPendingCreateAttempt({ draftId }) - onCreateError?.(resolved) - throw error + const resolved = error instanceof Error ? error : new Error("Failed to create agent"); + dispatch({ type: "CREATE_FAILED", message: resolved.message }); + markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" }); + clearPendingCreateAttempt({ draftId }); + onCreateError?.(resolved); + throw error; } }, [ @@ -208,8 +215,8 @@ export function useDraftAgentCreateFlow({ setPendingCreateAttempt, updatePendingAgentId, validateBeforeSubmit, - ] - ) + ], + ); return { machine, @@ -218,7 +225,7 @@ export function useDraftAgentCreateFlow({ optimisticStreamItems, draftAgent, handleCreateFromInput, - } + }; } -export type { CreateAttempt as DraftCreateAttempt } +export type { CreateAttempt as DraftCreateAttempt }; diff --git a/packages/app/src/hooks/use-explorer-open-gesture.ts b/packages/app/src/hooks/use-explorer-open-gesture.ts index 002f810cd..ad0a031cf 100644 --- a/packages/app/src/hooks/use-explorer-open-gesture.ts +++ b/packages/app/src/hooks/use-explorer-open-gesture.ts @@ -1,11 +1,6 @@ import { useMemo } from "react"; import { Gesture } from "react-native-gesture-handler"; -import { - Extrapolation, - interpolate, - runOnJS, - useSharedValue, -} from "react-native-reanimated"; +import { Extrapolation, interpolate, runOnJS, useSharedValue } from "react-native-reanimated"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; interface UseExplorerOpenGestureParams { @@ -13,18 +8,9 @@ interface UseExplorerOpenGestureParams { onOpen: () => void; } -export function useExplorerOpenGesture({ - enabled, - onOpen, -}: UseExplorerOpenGestureParams) { - const { - translateX, - backdropOpacity, - windowWidth, - animateToOpen, - animateToClose, - isGesturing, - } = useExplorerSidebarAnimation(); +export function useExplorerOpenGesture({ enabled, onOpen }: UseExplorerOpenGestureParams) { + const { translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing } = + useExplorerSidebarAnimation(); const touchStartX = useSharedValue(0); const touchStartY = useSharedValue(0); @@ -75,14 +61,14 @@ export function useExplorerOpenGesture({ // Right sidebar: start from closed position (+windowWidth) and move towards 0. const newTranslateX = Math.max( 0, - Math.min(windowWidth, windowWidth + event.translationX) + Math.min(windowWidth, windowWidth + event.translationX), ); translateX.value = newTranslateX; backdropOpacity.value = interpolate( newTranslateX, [windowWidth, 0], [0, 1], - Extrapolation.CLAMP + Extrapolation.CLAMP, ); }) .onEnd((event) => { @@ -111,6 +97,6 @@ export function useExplorerOpenGesture({ onOpen, touchStartX, touchStartY, - ] + ], ); } diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index 9fd9e736d..ab2d4470b 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -23,7 +23,7 @@ const FAVICON_IMAGES: Record["agents"] + agents: ReturnType["agents"], ): FaviconStatus { const hasRunning = agents.some((agent) => agent.status === "running"); if (hasRunning) { @@ -38,12 +38,12 @@ function deriveFaviconStatus( } function deriveMacDockBadgeCount( - agents: ReturnType["agents"] + agents: ReturnType["agents"], ): number | undefined { const attentionCount = agents.filter( (agent) => (agent.pendingPermissionCount ?? 0) > 0 || - (agent.requiresAttention && agent.attentionReason === "finished") + (agent.requiresAttention && agent.attentionReason === "finished"), ).length; if (attentionCount > 0) { return attentionCount; @@ -85,7 +85,11 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) { } function getSystemColorScheme(): ColorScheme { - if (Platform.OS !== "web" || typeof window === "undefined" || typeof window.matchMedia !== "function") { + if ( + Platform.OS !== "web" || + typeof window === "undefined" || + typeof window.matchMedia !== "function" + ) { return "dark"; } return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index d712f79e5..b9f5ada96 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -2,10 +2,7 @@ import { useState, useRef, useEffect } from "react"; import { Platform } from "react-native"; import type { ImageAttachment } from "@/components/message-input"; import { getDesktopHost } from "@/desktop/host"; -import { - persistAttachmentFromBlob, - persistAttachmentFromFileUri, -} from "@/attachments/service"; +import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service"; interface UseFileDropZoneOptions { onFilesDropped: (files: ImageAttachment[]) => void; @@ -140,43 +137,41 @@ export function useFileDropZone({ } try { - const unlisten = await desktopWindow.onDragDropEvent( - (event: DesktopDragDropEvent) => { - const payload = event.payload; - if (payload.type === "leave") { - setIsDragging(false); - return; - } - - if (payload.type === "enter" || payload.type === "over") { - if (!disabled) { - setIsDragging(true); - } - return; - } - - // Drop always ends the current drag operation. + const unlisten = await desktopWindow.onDragDropEvent((event: DesktopDragDropEvent) => { + const payload = event.payload; + if (payload.type === "leave") { setIsDragging(false); - - if (disabled) return; - - const imagePaths = payload.paths.filter(isImagePath); - if (imagePaths.length === 0) { - return; - } - - void Promise.all(imagePaths.map(filePathToImageAttachment)) - .then((attachments) => { - if (attachments.length === 0) { - return; - } - onFilesDroppedRef.current(attachments); - }) - .catch((error) => { - console.error("[useFileDropZone] Failed to persist dropped files:", error); - }); + return; } - ); + + if (payload.type === "enter" || payload.type === "over") { + if (!disabled) { + setIsDragging(true); + } + return; + } + + // Drop always ends the current drag operation. + setIsDragging(false); + + if (disabled) return; + + const imagePaths = payload.paths.filter(isImagePath); + if (imagePaths.length === 0) { + return; + } + + void Promise.all(imagePaths.map(filePathToImageAttachment)) + .then((attachments) => { + if (attachments.length === 0) { + return; + } + onFilesDroppedRef.current(attachments); + }) + .catch((error) => { + console.error("[useFileDropZone] Failed to persist dropped files:", error); + }); + }); if (disposed) { runCleanup(unlisten); @@ -247,9 +242,7 @@ export function useFileDropZone({ if (imageFiles.length === 0) return; try { - const attachments = await Promise.all( - imageFiles.map(fileToImageAttachment) - ); + const attachments = await Promise.all(imageFiles.map(fileToImageAttachment)); onFilesDroppedRef.current(attachments); } catch (error) { console.error("[useFileDropZone] Failed to process dropped files:", error); diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 5bad85628..766b1f978 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -37,9 +37,7 @@ function normalizeWorkspaceValue(value: string | null | undefined): string | nul return trimmed.length > 0 ? trimmed : null; } -export function buildWorkspaceExplorerStateKey( - scope: FileExplorerWorkspaceScope -): string | null { +export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope): string | null { const normalizedWorkspaceId = normalizeWorkspaceValue(scope.workspaceId); if (normalizedWorkspaceId) { return `workspace:${normalizedWorkspaceId}`; @@ -51,15 +49,13 @@ export function buildWorkspaceExplorerStateKey( return `root:${normalizedWorkspaceRoot}`; } -export function useFileExplorerActions( - params: { serverId: string } & FileExplorerWorkspaceScope -) { +export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) { const { serverId, workspaceId, workspaceRoot } = params; const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const normalizedWorkspaceRoot = useMemo( () => normalizeWorkspaceValue(workspaceRoot), - [workspaceRoot] + [workspaceRoot], ); const workspaceStateKey = useMemo( () => @@ -67,7 +63,7 @@ export function useFileExplorerActions( workspaceId, workspaceRoot: normalizedWorkspaceRoot, }), - [workspaceId, normalizedWorkspaceRoot] + [workspaceId, normalizedWorkspaceRoot], ); const updateExplorerState = useCallback( @@ -82,21 +78,17 @@ export function useFileExplorerActions( return next; }); }, - [serverId, setFileExplorer, workspaceStateKey] + [serverId, setFileExplorer, workspaceStateKey], ); const requestDirectoryListing = useCallback( - async ( - path: string, - options?: { recordHistory?: boolean; setCurrentPath?: boolean } - ) => { + async (path: string, options?: { recordHistory?: boolean; setCurrentPath?: boolean }) => { if (!workspaceStateKey) { return; } const normalizedPath = path && path.length > 0 ? path : "."; const shouldSetCurrentPath = options?.setCurrentPath ?? true; - const shouldRecordHistory = - options?.recordHistory ?? (shouldSetCurrentPath ? true : false); + const shouldRecordHistory = options?.recordHistory ?? (shouldSetCurrentPath ? true : false); updateExplorerState((state) => ({ ...state, @@ -138,7 +130,7 @@ export function useFileExplorerActions( const payload = await client.exploreFileSystem( normalizedWorkspaceRoot, normalizedPath, - "list" + "list", ); updateExplorerState((state) => { const nextState: AgentFileExplorerState = { @@ -167,7 +159,7 @@ export function useFileExplorerActions( })); } }, - [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey] + [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey], ); const requestFilePreview = useCallback( @@ -207,7 +199,7 @@ export function useFileExplorerActions( const payload = await client.exploreFileSystem( normalizedWorkspaceRoot, normalizedPath, - "file" + "file", ); updateExplorerState((state) => { const nextState: AgentFileExplorerState = { @@ -236,7 +228,7 @@ export function useFileExplorerActions( })); } }, - [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey] + [client, normalizedWorkspaceRoot, updateExplorerState, workspaceStateKey], ); const requestFileDownloadToken = useCallback( @@ -253,7 +245,7 @@ export function useFileExplorerActions( } return payload; }, - [client, normalizedWorkspaceRoot] + [client, normalizedWorkspaceRoot], ); const selectExplorerEntry = useCallback( @@ -263,7 +255,7 @@ export function useFileExplorerActions( selectedEntryPath: path, })); }, - [updateExplorerState] + [updateExplorerState], ); return { diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index b1ad8c1b2..7af0c063a 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -39,7 +39,7 @@ export interface UseFormPreferencesReturn { updatePreferences: (updates: Partial) => Promise; updateProviderPreferences: ( provider: AgentProvider, - updates: Partial + updates: Partial, ) => Promise; } @@ -58,7 +58,7 @@ export function useFormPreferences(): UseFormPreferencesReturn { (provider: AgentProvider): ProviderPreferences | undefined => { return preferences.providerPreferences?.[provider]; }, - [preferences.providerPreferences] + [preferences.providerPreferences], ); const updatePreferences = useCallback( @@ -70,7 +70,7 @@ export function useFormPreferences(): UseFormPreferencesReturn { queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); }, - [queryClient] + [queryClient], ); const updateProviderPreferences = useCallback( @@ -91,7 +91,7 @@ export function useFormPreferences(): UseFormPreferencesReturn { queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next)); }, - [queryClient] + [queryClient], ); return { diff --git a/packages/app/src/hooks/use-git-actions.ts b/packages/app/src/hooks/use-git-actions.ts index 4ab797d66..e028ef282 100644 --- a/packages/app/src/hooks/use-git-actions.ts +++ b/packages/app/src/hooks/use-git-actions.ts @@ -4,10 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; -import { - buildGitActions, - type GitActions, -} from "@/components/git-actions-policy"; +import { buildGitActions, type GitActions } from "@/components/git-actions-policy"; import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing"; import { openExternalUrl } from "@/utils/open-external-url"; @@ -44,8 +41,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); - const { status, isLoading: isStatusLoading } = - useCheckoutStatusQuery({ serverId, cwd }); + const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd }); const gitStatus = status && status.isGit ? status : null; const isGit = Boolean(gitStatus); const notGit = status !== null && !status.isGit && !status.error; @@ -53,10 +49,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const hasUncommittedChanges = Boolean(gitStatus?.isDirty); - const { - status: prStatus, - githubFeaturesEnabled, - } = useCheckoutPrStatusQuery({ + const { status: prStatus, githubFeaturesEnabled } = useCheckoutPrStatusQuery({ serverId, cwd, enabled: isGit, @@ -98,7 +91,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use // Ignore persistence failures; default will reset to "merge". } }, - [shipDefaultStorageKey] + [shipDefaultStorageKey], ); useEffect(() => { @@ -107,22 +100,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use // Store selectors const commitStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "commit" }) + state.getStatus({ serverId, cwd, actionId: "commit" }), ); const pushStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "push" }) + state.getStatus({ serverId, cwd, actionId: "push" }), ); const prCreateStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "create-pr" }) + state.getStatus({ serverId, cwd, actionId: "create-pr" }), ); const mergeStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-branch" }) + state.getStatus({ serverId, cwd, actionId: "merge-branch" }), ); const mergeFromBaseStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "merge-from-base" }) + state.getStatus({ serverId, cwd, actionId: "merge-from-base" }), ); const archiveStatus = useCheckoutGitActionsStore((state) => - state.getStatus({ serverId, cwd, actionId: "archive-worktree" }) + state.getStatus({ serverId, cwd, actionId: "archive-worktree" }), ); const runCommit = useCheckoutGitActionsStore((state) => state.commit); @@ -228,14 +221,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const commitDisabled = actionsDisabled || commitStatus === "pending"; const prDisabled = actionsDisabled || prCreateStatus === "pending"; - const mergeDisabled = - actionsDisabled || mergeStatus === "pending"; - const mergeFromBaseDisabled = - actionsDisabled || mergeFromBaseStatus === "pending"; - const pushDisabled = - actionsDisabled || pushStatus === "pending"; - const archiveDisabled = - actionsDisabled || archiveStatus === "pending"; + const mergeDisabled = actionsDisabled || mergeStatus === "pending"; + const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending"; + const pushDisabled = actionsDisabled || pushStatus === "pending"; + const archiveDisabled = actionsDisabled || archiveStatus === "pending"; const branchLabel = gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD" @@ -308,12 +297,40 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use }, }); }, [ - isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch, githubFeaturesEnabled, - hasUncommittedChanges, aheadOfOrigin, behindOfOrigin, shipDefault, baseRefLabel, shouldPromoteArchive, - commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, - commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, - handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, - icons, baseRef, + isGit, + hasRemote, + hasPullRequest, + prStatus?.url, + aheadCount, + isPaseoOwnedWorktree, + isOnBaseBranch, + githubFeaturesEnabled, + hasUncommittedChanges, + aheadOfOrigin, + behindOfOrigin, + shipDefault, + baseRefLabel, + shouldPromoteArchive, + commitDisabled, + pushDisabled, + prDisabled, + mergeDisabled, + mergeFromBaseDisabled, + archiveDisabled, + commitStatus, + pushStatus, + prCreateStatus, + mergeStatus, + mergeFromBaseStatus, + archiveStatus, + handleCommit, + handlePush, + handleCreatePr, + handleMergeBranch, + handleMergeFromBase, + handleArchiveWorktree, + icons, + baseRef, ]); return { gitActions, branchLabel, actionError, isGit }; diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index b1c268bfb..fed8894fc 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -27,7 +27,10 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult { } if (!currentPermission?.granted) { - Alert.alert("Permission required", "Please allow access to your photo library to attach images."); + Alert.alert( + "Permission required", + "Please allow access to your photo library to attach images.", + ); return false; } diff --git a/packages/app/src/hooks/use-is-local-daemon.ts b/packages/app/src/hooks/use-is-local-daemon.ts index 90744804d..63c4afb69 100644 --- a/packages/app/src/hooks/use-is-local-daemon.ts +++ b/packages/app/src/hooks/use-is-local-daemon.ts @@ -1,23 +1,23 @@ -import { useQuery } from '@tanstack/react-query' -import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from '@/desktop/daemon/desktop-daemon' +import { useQuery } from "@tanstack/react-query"; +import { getDesktopDaemonStatus, shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; -const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ['desktop-daemon-server-id'] as const +const DESKTOP_DAEMON_SERVER_ID_QUERY_KEY = ["desktop-daemon-server-id"] as const; interface DesktopDaemonServerIdResult { - serverId: string | null + serverId: string | null; } async function loadDesktopDaemonServerId(): Promise { - const status = await getDesktopDaemonStatus() - const serverId = status.serverId.trim() + const status = await getDesktopDaemonStatus(); + const serverId = status.serverId.trim(); return { serverId: serverId.length > 0 ? serverId : null, - } + }; } export function useIsLocalDaemon(serverId: string): boolean { - const normalizedServerId = serverId.trim() - const isDesktop = shouldUseDesktopDaemon() + const normalizedServerId = serverId.trim(); + const isDesktop = shouldUseDesktopDaemon(); const query = useQuery({ queryKey: DESKTOP_DAEMON_SERVER_ID_QUERY_KEY, @@ -30,11 +30,11 @@ export function useIsLocalDaemon(serverId: string): boolean { refetchOnReconnect: false, refetchOnWindowFocus: false, retry: false, - }) + }); if (!isDesktop || normalizedServerId.length === 0) { - return false + return false; } - return query.data?.serverId === normalizedServerId + return query.data?.serverId === normalizedServerId; } diff --git a/packages/app/src/hooks/use-keyboard-action-handler.ts b/packages/app/src/hooks/use-keyboard-action-handler.ts index e4356e5d1..3de3a1e7a 100644 --- a/packages/app/src/hooks/use-keyboard-action-handler.ts +++ b/packages/app/src/hooks/use-keyboard-action-handler.ts @@ -26,12 +26,5 @@ export function useKeyboardActionHandler(input: UseKeyboardActionHandlerInput) { isActive: input.isActive, handle: input.handle, }); - }, [ - input.actions, - input.enabled, - input.handle, - input.handlerId, - input.isActive, - input.priority, - ]); + }, [input.actions, input.enabled, input.handle, input.handlerId, input.isActive, input.priority]); } diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index cd121f7e9..28d0219b8 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -89,7 +89,7 @@ export function useKeyboardShortcuts({ const currentIndex = targets.findIndex( (target) => target.serverId === workspaceRoute.serverId && - target.workspaceId === workspaceRoute.workspaceId + target.workspaceId === workspaceRoute.workspaceId, ); const fromIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0; const nextIndex = (fromIndex + delta + targets.length) % targets.length; @@ -106,9 +106,7 @@ export function useKeyboardShortcuts({ return true; }; - const dispatchMessageInputAction = ( - kind: MessageInputKeyboardActionKind - ): boolean => { + const dispatchMessageInputAction = (kind: MessageInputKeyboardActionKind): boolean => { switch (kind) { case "focus": return keyboardActionDispatcher.dispatch({ @@ -229,7 +227,7 @@ export function useKeyboardShortcuts({ const active = document.activeElement; const activeEl = active instanceof HTMLElement ? active : null; setCommandCenterFocusRestoreElement( - (targetEl as HTMLElement | null) ?? activeEl ?? null + (targetEl as HTMLElement | null) ?? activeEl ?? null, ); } store.setCommandCenterOpen(!store.commandCenterOpen); diff --git a/packages/app/src/hooks/use-open-project-picker.ts b/packages/app/src/hooks/use-open-project-picker.ts index d96bb6d31..9a3193a41 100644 --- a/packages/app/src/hooks/use-open-project-picker.ts +++ b/packages/app/src/hooks/use-open-project-picker.ts @@ -7,9 +7,7 @@ import { useOpenProject } from "./use-open-project"; export function useOpenProjectPicker(serverId: string | null): () => Promise { const normalizedServerId = serverId?.trim() ?? ""; const isLocalDaemon = useIsLocalDaemon(normalizedServerId); - const setProjectPickerOpen = useKeyboardShortcutsStore( - (state) => state.setProjectPickerOpen - ); + const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen); const openProject = useOpenProject(serverId); return useCallback(async () => { diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index b04b807dc..ed353dccf 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -2,22 +2,15 @@ import { router } from "expo-router"; import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { - normalizeWorkspaceDescriptor, - useSessionStore, -} from "@/stores/session-store"; +import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; -export function useOpenProject( - serverId: string | null -): (path: string) => Promise { +export function useOpenProject(serverId: string | null): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; const toast = useToast(); const client = useHostRuntimeClient(normalizedServerId); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); - const setHasHydratedWorkspaces = useSessionStore( - (state) => state.setHasHydratedWorkspaces - ); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( async (path: string) => { @@ -32,31 +25,21 @@ export function useOpenProject( throw new Error(payload.error || "Failed to open project"); } - mergeWorkspaces(normalizedServerId, [ - normalizeWorkspaceDescriptor(payload.workspace), - ]); + mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]); setHasHydratedWorkspaces(normalizedServerId, true); router.replace( prepareWorkspaceTab({ serverId: normalizedServerId, workspaceId: payload.workspace.id, target: { kind: "draft", draftId: "new" }, - }) as any + }) as any, ); return true; } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to open project" - ); + toast.error(error instanceof Error ? error.message : "Failed to open project"); return false; } }, - [ - client, - mergeWorkspaces, - normalizedServerId, - setHasHydratedWorkspaces, - toast, - ] + [client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast], ); } diff --git a/packages/app/src/hooks/use-push-token-registration.ts b/packages/app/src/hooks/use-push-token-registration.ts index 73337e5af..61a686bf6 100644 --- a/packages/app/src/hooks/use-push-token-registration.ts +++ b/packages/app/src/hooks/use-push-token-registration.ts @@ -25,10 +25,7 @@ async function ensurePushPermission(): Promise { return requested.status === "granted"; } -export function usePushTokenRegistration(params: { - client: DaemonClient; - serverId: string; -}): void { +export function usePushTokenRegistration(params: { client: DaemonClient; serverId: string }): void { const { client, serverId } = params; const tokenRef = useRef(null); const lastSentTokenRef = useRef(null); diff --git a/packages/app/src/hooks/use-recent-paths.ts b/packages/app/src/hooks/use-recent-paths.ts index e4127f995..310a3c57b 100644 --- a/packages/app/src/hooks/use-recent-paths.ts +++ b/packages/app/src/hooks/use-recent-paths.ts @@ -1,7 +1,7 @@ -import { useState, useEffect, useCallback } from 'react'; -import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useState, useEffect, useCallback } from "react"; +import AsyncStorage from "@react-native-async-storage/async-storage"; -const STORAGE_KEY = '@paseo:recent-paths'; +const STORAGE_KEY = "@paseo:recent-paths"; const MAX_RECENT_PATHS = 3; export interface UseRecentPathsReturn { @@ -28,33 +28,36 @@ export function useRecentPaths(): UseRecentPathsReturn { setRecentPaths(parsed); } } catch (error) { - console.error('[RecentPaths] Failed to load recent paths:', error); + console.error("[RecentPaths] Failed to load recent paths:", error); // Continue with empty array } finally { setIsLoading(false); } } - const addRecentPath = useCallback(async (path: string) => { - try { - // Remove duplicates and add to front - const filtered = recentPaths.filter((p) => p !== path); - const updated = [path, ...filtered].slice(0, MAX_RECENT_PATHS); + const addRecentPath = useCallback( + async (path: string) => { + try { + // Remove duplicates and add to front + const filtered = recentPaths.filter((p) => p !== path); + const updated = [path, ...filtered].slice(0, MAX_RECENT_PATHS); - setRecentPaths(updated); - await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); - } catch (error) { - console.error('[RecentPaths] Failed to save recent path:', error); - throw error; - } - }, [recentPaths]); + setRecentPaths(updated); + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch (error) { + console.error("[RecentPaths] Failed to save recent path:", error); + throw error; + } + }, + [recentPaths], + ); const clearRecentPaths = useCallback(async () => { try { setRecentPaths([]); await AsyncStorage.removeItem(STORAGE_KEY); } catch (error) { - console.error('[RecentPaths] Failed to clear recent paths:', error); + console.error("[RecentPaths] Failed to clear recent paths:", error); throw error; } }, []); diff --git a/packages/app/src/hooks/use-session-directory.ts b/packages/app/src/hooks/use-session-directory.ts index 7f9a0c5dc..a718c0cbe 100644 --- a/packages/app/src/hooks/use-session-directory.ts +++ b/packages/app/src/hooks/use-session-directory.ts @@ -17,24 +17,24 @@ export function useSessionForServer(serverId: string | null): SessionState | nul export function useSessionForServer( serverId: string | null, selector: SessionSelector, - equalityFn?: EqualityFn + equalityFn?: EqualityFn, ): T; export function useSessionForServer( serverId: string | null, selector?: SessionSelector, - equalityFn?: EqualityFn + equalityFn?: EqualityFn, ): SessionState | null | T { const storeSelector = useCallback( (state: ReturnType): SessionState | null | T => { - const session = serverId ? state.sessions[serverId] ?? null : null; + const session = serverId ? (state.sessions[serverId] ?? null) : null; return selector ? selector(session) : session; }, - [selector, serverId] + [selector, serverId], ); return useStoreWithEqualityFn( useSessionStore, storeSelector, - equalityFn as EqualityFn + equalityFn as EqualityFn, ); } diff --git a/packages/app/src/hooks/use-settings.test.ts b/packages/app/src/hooks/use-settings.test.ts index 18370ae19..353c21af8 100644 --- a/packages/app/src/hooks/use-settings.test.ts +++ b/packages/app/src/hooks/use-settings.test.ts @@ -26,7 +26,7 @@ describe("use-settings", () => { expect(result).toEqual(mod.DEFAULT_APP_SETTINGS); expect(asyncStorageMock.setItem).toHaveBeenCalledWith( mod.APP_SETTINGS_KEY, - JSON.stringify(mod.DEFAULT_APP_SETTINGS) + JSON.stringify(mod.DEFAULT_APP_SETTINGS), ); }); diff --git a/packages/app/src/hooks/use-settings.ts b/packages/app/src/hooks/use-settings.ts index 6bb837c99..19f7cf017 100644 --- a/packages/app/src/hooks/use-settings.ts +++ b/packages/app/src/hooks/use-settings.ts @@ -36,7 +36,8 @@ export function useAppSettings(): UseAppSettingsReturn { const updateSettings = useCallback( async (updates: Partial) => { try { - const prev = queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS; + const prev = + queryClient.getQueryData(APP_SETTINGS_QUERY_KEY) ?? DEFAULT_APP_SETTINGS; const next = { ...prev, ...updates }; queryClient.setQueryData(APP_SETTINGS_QUERY_KEY, next); await AsyncStorage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); @@ -45,7 +46,7 @@ export function useAppSettings(): UseAppSettingsReturn { throw err; } }, - [queryClient] + [queryClient], ); const resetSettings = useCallback(async () => { diff --git a/packages/app/src/hooks/use-sidebar-agent-sections.ts b/packages/app/src/hooks/use-sidebar-agent-sections.ts index 319e06e25..6631ea937 100644 --- a/packages/app/src/hooks/use-sidebar-agent-sections.ts +++ b/packages/app/src/hooks/use-sidebar-agent-sections.ts @@ -55,15 +55,14 @@ export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSecti const { activeGroups } = useMemo( () => groupAgents(agents, { - getRemoteUrl: (agent) => - remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null, + getRemoteUrl: (agent) => remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null, }), - [agents, remoteUrlByAgentKey] + [agents, remoteUrlByAgentKey], ); const sortedGroups = useMemo( () => sortProjectsByStoredOrder(activeGroups, projectOrder), - [activeGroups, projectOrder] + [activeGroups, projectOrder], ); const sections: SidebarSectionData[] = useMemo(() => { diff --git a/packages/app/src/hooks/use-sidebar-shortcut-model.ts b/packages/app/src/hooks/use-sidebar-shortcut-model.ts index 8aac29e3f..63602c314 100644 --- a/packages/app/src/hooks/use-sidebar-shortcut-model.ts +++ b/packages/app/src/hooks/use-sidebar-shortcut-model.ts @@ -1,17 +1,17 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import type { SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list' -import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store' -import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts' -import { isSidebarProjectFlattened } from '@/utils/sidebar-project-row-model' +import { useCallback, useEffect, useMemo, useState } from "react"; +import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts"; +import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model"; export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { - const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) + const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()); const setSidebarShortcutWorkspaceTargets = useKeyboardShortcutsStore( - (state) => state.setSidebarShortcutWorkspaceTargets - ) + (state) => state.setSidebarShortcutWorkspaceTargets, + ); const setVisibleWorkspaceTargets = useKeyboardShortcutsStore( - (state) => state.setVisibleWorkspaceTargets - ) + (state) => state.setVisibleWorkspaceTargets, + ); const shortcutModel = useMemo( () => @@ -19,71 +19,71 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) { projects, collapsedProjectKeys, }), - [collapsedProjectKeys, projects] - ) + [collapsedProjectKeys, projects], + ); useEffect(() => { setCollapsedProjectKeys((prev) => { const collapsibleProjectKeys = new Set( projects .filter((project) => !isSidebarProjectFlattened(project)) - .map((project) => project.projectKey) - ) - const next = new Set() + .map((project) => project.projectKey), + ); + const next = new Set(); for (const key of prev) { if (collapsibleProjectKeys.has(key)) { - next.add(key) + next.add(key); } } - return next - }) - }, [projects]) + return next; + }); + }, [projects]); useEffect(() => { - setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets) - setVisibleWorkspaceTargets(shortcutModel.visibleTargets) + setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets); + setVisibleWorkspaceTargets(shortcutModel.visibleTargets); }, [ setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets, shortcutModel.shortcutTargets, shortcutModel.visibleTargets, - ]) + ]); useEffect(() => { return () => { - setSidebarShortcutWorkspaceTargets([]) - setVisibleWorkspaceTargets([]) - } - }, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]) + setSidebarShortcutWorkspaceTargets([]); + setVisibleWorkspaceTargets([]); + }; + }, [setSidebarShortcutWorkspaceTargets, setVisibleWorkspaceTargets]); const toggleProjectCollapsed = useCallback((projectKey: string) => { setCollapsedProjectKeys((prev) => { - const next = new Set(prev) + const next = new Set(prev); if (next.has(projectKey)) { - next.delete(projectKey) + next.delete(projectKey); } else { - next.add(projectKey) + next.add(projectKey); } - return next - }) - }, []) + return next; + }); + }, []); const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => { setCollapsedProjectKeys((prev) => { - const next = new Set(prev) + const next = new Set(prev); if (collapsed) { - next.add(projectKey) + next.add(projectKey); } else { - next.delete(projectKey) + next.delete(projectKey); } - return next - }) - }, []) + return next; + }); + }, []); return { collapsedProjectKeys, shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey, setProjectCollapsed, toggleProjectCollapsed, - } + }; } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index 2afda94b0..88f4925c6 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from "vitest"; import { appendMissingOrderKeys, applyStoredOrdering, buildSidebarProjectsFromWorkspaces, -} from './use-sidebar-workspaces-list' -import type { WorkspaceDescriptor } from '@/stores/session-store' +} from "./use-sidebar-workspaces-list"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; interface OrderedItem { - key: string + key: string; } function item(key: string): OrderedItem { - return { key } + return { key }; } function workspace( @@ -21,7 +21,7 @@ function workspace( WorkspaceDescriptor, "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" > - > + >, ): WorkspaceDescriptor { return { id: input.id, @@ -34,230 +34,237 @@ function workspace( status: input.status, activityAt: input.activityAt, diffStat: null, - } + }; } -describe('applyStoredOrdering', () => { - it('keeps unknown items on the baseline while applying stored order', () => { +describe("applyStoredOrdering", () => { + it("keeps unknown items on the baseline while applying stored order", () => { const result = applyStoredOrdering({ - items: [item('new'), item('a'), item('b')], - storedOrder: ['b', 'a'], + items: [item("new"), item("a"), item("b")], + storedOrder: ["b", "a"], getKey: (entry) => entry.key, - }) + }); - expect(result.map((entry) => entry.key)).toEqual(['new', 'b', 'a']) - }) + expect(result.map((entry) => entry.key)).toEqual(["new", "b", "a"]); + }); - it('ignores stale and duplicate stored keys', () => { + it("ignores stale and duplicate stored keys", () => { const result = applyStoredOrdering({ - items: [item('x'), item('y')], - storedOrder: ['missing', 'y', 'y', 'x'], + items: [item("x"), item("y")], + storedOrder: ["missing", "y", "y", "x"], getKey: (entry) => entry.key, - }) + }); - expect(result.map((entry) => entry.key)).toEqual(['y', 'x']) - }) + expect(result.map((entry) => entry.key)).toEqual(["y", "x"]); + }); - it('returns baseline when there is no persisted order', () => { - const baseline = [item('first'), item('second')] + it("returns baseline when there is no persisted order", () => { + const baseline = [item("first"), item("second")]; const result = applyStoredOrdering({ items: baseline, storedOrder: [], getKey: (entry) => entry.key, - }) + }); - expect(result).toBe(baseline) - }) -}) + expect(result).toBe(baseline); + }); +}); -describe('appendMissingOrderKeys', () => { - it('appends unseen keys while preserving existing order', () => { +describe("appendMissingOrderKeys", () => { + it("appends unseen keys while preserving existing order", () => { const result = appendMissingOrderKeys({ - currentOrder: ['project-b', 'project-a'], - visibleKeys: ['project-a', 'project-b', 'project-c'], - }) + currentOrder: ["project-b", "project-a"], + visibleKeys: ["project-a", "project-b", "project-c"], + }); - expect(result).toEqual(['project-b', 'project-a', 'project-c']) - }) + expect(result).toEqual(["project-b", "project-a", "project-c"]); + }); - it('returns the same array when there are no unseen keys', () => { - const currentOrder = ['project-a', 'project-b'] + it("returns the same array when there are no unseen keys", () => { + const currentOrder = ["project-a", "project-b"]; const result = appendMissingOrderKeys({ currentOrder, - visibleKeys: ['project-b', 'project-a'], - }) + visibleKeys: ["project-b", "project-a"], + }); - expect(result).toBe(currentOrder) - }) -}) + expect(result).toBe(currentOrder); + }); +}); -describe('buildSidebarProjectsFromWorkspaces', () => { - it('uses workspace descriptor name and status directly', () => { +describe("buildSidebarProjectsFromWorkspaces", () => { + it("uses workspace descriptor name and status directly", () => { const workspaces: WorkspaceDescriptor[] = [ workspace({ - id: '/repo/main', - projectId: 'project-1', - name: 'feat/hard-cut', - status: 'failed', - activityAt: new Date('2026-01-01T00:00:00.000Z'), + id: "/repo/main", + projectId: "project-1", + name: "feat/hard-cut", + status: "failed", + activityAt: new Date("2026-01-01T00:00:00.000Z"), }), - ] + ]; const projects = buildSidebarProjectsFromWorkspaces({ - serverId: 'srv', + serverId: "srv", workspaces, projectOrder: [], workspaceOrderByScope: {}, - }) + }); - expect(projects).toHaveLength(1) - expect(projects[0]?.statusBucket).toBe('failed') - expect(projects[0]?.workspaces[0]?.name).toBe('feat/hard-cut') - expect(projects[0]?.workspaces[0]?.statusBucket).toBe('failed') - }) + expect(projects).toHaveLength(1); + expect(projects[0]?.statusBucket).toBe("failed"); + expect(projects[0]?.workspaces[0]?.name).toBe("feat/hard-cut"); + expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed"); + }); - it('preserves stored project order even when activity changes', () => { + it("preserves stored project order even when activity changes", () => { const initialWorkspaces: WorkspaceDescriptor[] = [ workspace({ - id: '/repo/b', - projectId: 'project-b', - name: 'feat/b', - status: 'running', - activityAt: new Date('2026-01-02T00:00:00.000Z'), + id: "/repo/b", + projectId: "project-b", + name: "feat/b", + status: "running", + activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ - id: '/repo/a', - projectId: 'project-a', - name: 'feat/a', - status: 'running', - activityAt: new Date('2026-01-01T00:00:00.000Z'), + id: "/repo/a", + projectId: "project-a", + name: "feat/a", + status: "running", + activityAt: new Date("2026-01-01T00:00:00.000Z"), }), - ] + ]; const seededOrder = appendMissingOrderKeys({ currentOrder: [], visibleKeys: buildSidebarProjectsFromWorkspaces({ - serverId: 'srv', + serverId: "srv", workspaces: initialWorkspaces, projectOrder: [], workspaceOrderByScope: {}, }).map((project) => project.projectKey), - }) + }); const updatedProjects = buildSidebarProjectsFromWorkspaces({ - serverId: 'srv', + serverId: "srv", workspaces: [ workspace({ - id: '/repo/b', - projectId: 'project-b', - name: 'feat/b', - status: 'running', - activityAt: new Date('2026-01-02T00:00:00.000Z'), + id: "/repo/b", + projectId: "project-b", + name: "feat/b", + status: "running", + activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ - id: '/repo/a', - projectId: 'project-a', - name: 'feat/a', - status: 'running', - activityAt: new Date('2026-01-03T00:00:00.000Z'), + id: "/repo/a", + projectId: "project-a", + name: "feat/a", + status: "running", + activityAt: new Date("2026-01-03T00:00:00.000Z"), }), ], projectOrder: seededOrder, workspaceOrderByScope: {}, - }) + }); - expect(updatedProjects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a']) - }) + expect(updatedProjects.map((project) => project.projectKey)).toEqual([ + "project-b", + "project-a", + ]); + }); - it('appends new projects after the stored project order', () => { + it("appends new projects after the stored project order", () => { const projects = buildSidebarProjectsFromWorkspaces({ - serverId: 'srv', + serverId: "srv", workspaces: [ workspace({ - id: '/repo/c', - projectId: 'project-c', - name: 'feat/c', - status: 'running', - activityAt: new Date('2026-01-04T00:00:00.000Z'), + id: "/repo/c", + projectId: "project-c", + name: "feat/c", + status: "running", + activityAt: new Date("2026-01-04T00:00:00.000Z"), }), workspace({ - id: '/repo/b', - projectId: 'project-b', - name: 'feat/b', - status: 'running', - activityAt: new Date('2026-01-02T00:00:00.000Z'), + id: "/repo/b", + projectId: "project-b", + name: "feat/b", + status: "running", + activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ - id: '/repo/a', - projectId: 'project-a', - name: 'feat/a', - status: 'running', - activityAt: new Date('2026-01-01T00:00:00.000Z'), + id: "/repo/a", + projectId: "project-a", + name: "feat/a", + status: "running", + activityAt: new Date("2026-01-01T00:00:00.000Z"), }), ], - projectOrder: ['project-b', 'project-a', 'project-c'], + projectOrder: ["project-b", "project-a", "project-c"], workspaceOrderByScope: {}, - }) + }); - expect(projects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a', 'project-c']) - }) + expect(projects.map((project) => project.projectKey)).toEqual([ + "project-b", + "project-a", + "project-c", + ]); + }); - it('preserves stored workspace order when workspace activity changes', () => { + it("preserves stored workspace order when workspace activity changes", () => { const initialProjects = buildSidebarProjectsFromWorkspaces({ - serverId: 'srv', + serverId: "srv", workspaces: [ workspace({ - id: '/repo/main', - projectId: 'project-1', - name: 'main', - status: 'running', - activityAt: new Date('2026-01-02T00:00:00.000Z'), + id: "/repo/main", + projectId: "project-1", + name: "main", + status: "running", + activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ - id: '/repo/feature', - projectId: 'project-1', - name: 'feature', - status: 'running', - activityAt: new Date('2026-01-01T00:00:00.000Z'), + id: "/repo/feature", + projectId: "project-1", + name: "feature", + status: "running", + activityAt: new Date("2026-01-01T00:00:00.000Z"), }), ], - projectOrder: ['project-1'], + projectOrder: ["project-1"], workspaceOrderByScope: {}, - }) + }); const seededWorkspaceOrder = appendMissingOrderKeys({ currentOrder: [], visibleKeys: initialProjects[0]?.workspaces.map((workspace) => workspace.workspaceKey) ?? [], - }) + }); const projects = buildSidebarProjectsFromWorkspaces({ - serverId: 'srv', + serverId: "srv", workspaces: [ workspace({ - id: '/repo/main', - projectId: 'project-1', - name: 'main', - status: 'running', - activityAt: new Date('2026-01-02T00:00:00.000Z'), + id: "/repo/main", + projectId: "project-1", + name: "main", + status: "running", + activityAt: new Date("2026-01-02T00:00:00.000Z"), }), workspace({ - id: '/repo/feature', - projectId: 'project-1', - name: 'feature', - status: 'running', - activityAt: new Date('2026-01-03T00:00:00.000Z'), + id: "/repo/feature", + projectId: "project-1", + name: "feature", + status: "running", + activityAt: new Date("2026-01-03T00:00:00.000Z"), }), ], - projectOrder: ['project-1'], + projectOrder: ["project-1"], workspaceOrderByScope: { - 'srv::project-1': seededWorkspaceOrder, + "srv::project-1": seededWorkspaceOrder, }, - }) + }); expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([ - '/repo/main', - '/repo/feature', - ]) - }) -}) + "/repo/main", + "/repo/feature", + ]); + }); +}); diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 6b82caeef..205deb6a4 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,45 +1,45 @@ -import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react' -import { normalizeWorkspaceDescriptor, useSessionStore } from '@/stores/session-store' -import { getHostRuntimeStore } from '@/runtime/host-runtime' -import { useSidebarOrderStore } from '@/stores/sidebar-order-store' -import type { WorkspaceDescriptor } from '@/stores/session-store' -import { projectDisplayNameFromProjectId } from '@/utils/project-display-name' +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; -const EMPTY_ORDER: string[] = [] -const EMPTY_PROJECTS: SidebarProjectEntry[] = [] -const EMPTY_WORKSPACE_ORDER_SCOPE: Record = {} +const EMPTY_ORDER: string[] = []; +const EMPTY_PROJECTS: SidebarProjectEntry[] = []; +const EMPTY_WORKSPACE_ORDER_SCOPE: Record = {}; -export type SidebarStateBucket = WorkspaceDescriptor['status'] +export type SidebarStateBucket = WorkspaceDescriptor["status"]; export interface SidebarWorkspaceEntry { - workspaceKey: string - serverId: string - workspaceId: string - workspaceKind: WorkspaceDescriptor['workspaceKind'] - name: string - activityAt: Date | null - statusBucket: SidebarStateBucket - diffStat: { additions: number; deletions: number } | null + workspaceKey: string; + serverId: string; + workspaceId: string; + workspaceKind: WorkspaceDescriptor["workspaceKind"]; + name: string; + activityAt: Date | null; + statusBucket: SidebarStateBucket; + diffStat: { additions: number; deletions: number } | null; } export interface SidebarProjectEntry { - projectKey: string - projectName: string - projectKind: WorkspaceDescriptor['projectKind'] - iconWorkingDir: string - statusBucket: SidebarStateBucket - activeCount: number - totalWorkspaces: number - latestActivityAt: Date | null - workspaces: SidebarWorkspaceEntry[] + projectKey: string; + projectName: string; + projectKind: WorkspaceDescriptor["projectKind"]; + iconWorkingDir: string; + statusBucket: SidebarStateBucket; + activeCount: number; + totalWorkspaces: number; + latestActivityAt: Date | null; + workspaces: SidebarWorkspaceEntry[]; } export interface SidebarWorkspacesListResult { - projects: SidebarProjectEntry[] - isLoading: boolean - isInitialLoad: boolean - isRevalidating: boolean - refreshAll: () => void + projects: SidebarProjectEntry[]; + isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; + refreshAll: () => void; } const SIDEBAR_BUCKET_PRIORITY: Record = { @@ -48,80 +48,83 @@ const SIDEBAR_BUCKET_PRIORITY: Record = { running: 2, failed: 3, needs_input: 4, -} +}; function aggregateBucket( current: SidebarStateBucket, - candidate: SidebarStateBucket + candidate: SidebarStateBucket, ): SidebarStateBucket { - return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current] ? candidate : current + return SIDEBAR_BUCKET_PRIORITY[candidate] > SIDEBAR_BUCKET_PRIORITY[current] + ? candidate + : current; } function compareWorkspaceBaseline( left: SidebarWorkspaceEntry, - right: SidebarWorkspaceEntry + right: SidebarWorkspaceEntry, ): number { if (left.activityAt && right.activityAt) { - const dateDelta = right.activityAt.getTime() - left.activityAt.getTime() + const dateDelta = right.activityAt.getTime() - left.activityAt.getTime(); if (dateDelta !== 0) { - return dateDelta + return dateDelta; } } else if (left.activityAt || right.activityAt) { - return left.activityAt ? -1 : 1 + return left.activityAt ? -1 : 1; } const nameDelta = left.name.localeCompare(right.name, undefined, { numeric: true, - sensitivity: 'base', - }) + sensitivity: "base", + }); if (nameDelta !== 0) { - return nameDelta + return nameDelta; } return left.workspaceId.localeCompare(right.workspaceId, undefined, { numeric: true, - sensitivity: 'base', - }) + sensitivity: "base", + }); } function compareProjectBaseline(left: SidebarProjectEntry, right: SidebarProjectEntry): number { if (left.latestActivityAt && right.latestActivityAt) { - const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime() + const dateDelta = right.latestActivityAt.getTime() - left.latestActivityAt.getTime(); if (dateDelta !== 0) { - return dateDelta + return dateDelta; } } else if (left.latestActivityAt || right.latestActivityAt) { - return left.latestActivityAt ? -1 : 1 + return left.latestActivityAt ? -1 : 1; } return left.projectName.localeCompare(right.projectName, undefined, { numeric: true, - sensitivity: 'base', - }) + sensitivity: "base", + }); } export function buildSidebarProjectsFromWorkspaces(input: { - serverId: string - workspaces: Iterable - projectOrder: string[] - workspaceOrderByScope: Record + serverId: string; + workspaces: Iterable; + projectOrder: string[]; + workspaceOrderByScope: Record; }): SidebarProjectEntry[] { - const byProject = new Map() + const byProject = new Map(); for (const workspace of input.workspaces) { const project = byProject.get(workspace.projectId) ?? ({ projectKey: workspace.projectId, - projectName: workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId), + projectName: + workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId), projectKind: workspace.projectKind, iconWorkingDir: workspace.projectRootPath || workspace.id, - statusBucket: 'done', + statusBucket: "done", activeCount: 0, totalWorkspaces: 0, latestActivityAt: null, workspaces: [], - } satisfies SidebarProjectEntry) + } satisfies SidebarProjectEntry); const row: SidebarWorkspaceEntry = { workspaceKey: `${input.serverId}:${workspace.id}`, @@ -132,255 +135,256 @@ export function buildSidebarProjectsFromWorkspaces(input: { activityAt: workspace.activityAt, statusBucket: workspace.status, diffStat: workspace.diffStat, - } + }; - project.workspaces.push(row) - project.totalWorkspaces += 1 - if (workspace.status !== 'done') { - project.activeCount += 1 + project.workspaces.push(row); + project.totalWorkspaces += 1; + if (workspace.status !== "done") { + project.activeCount += 1; } - project.statusBucket = aggregateBucket(project.statusBucket, workspace.status) + project.statusBucket = aggregateBucket(project.statusBucket, workspace.status); if ( !project.latestActivityAt || (workspace.activityAt && workspace.activityAt.getTime() > project.latestActivityAt.getTime()) ) { - project.latestActivityAt = workspace.activityAt + project.latestActivityAt = workspace.activityAt; } - byProject.set(workspace.projectId, project) + byProject.set(workspace.projectId, project); } const baselineProjects = Array.from(byProject.values()).map((project) => { - const baselineWorkspaces = [...project.workspaces] - baselineWorkspaces.sort(compareWorkspaceBaseline) + const baselineWorkspaces = [...project.workspaces]; + baselineWorkspaces.sort(compareWorkspaceBaseline); - const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey) + const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(input.serverId, project.projectKey); const orderedWorkspaces = applyStoredOrdering({ items: baselineWorkspaces, storedOrder: input.workspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER, getKey: (workspace) => workspace.workspaceKey, - }) + }); return { ...project, workspaces: orderedWorkspaces, - } - }) + }; + }); - baselineProjects.sort(compareProjectBaseline) + baselineProjects.sort(compareProjectBaseline); return applyStoredOrdering({ items: baselineProjects, storedOrder: input.projectOrder, getKey: (project) => project.projectKey, - }) + }); } export function applyStoredOrdering(input: { - items: T[] - storedOrder: string[] - getKey: (item: T) => string + items: T[]; + storedOrder: string[]; + getKey: (item: T) => string; }): T[] { if (input.items.length <= 1 || input.storedOrder.length === 0) { - return input.items + return input.items; } - const itemByKey = new Map() + const itemByKey = new Map(); for (const item of input.items) { - itemByKey.set(input.getKey(item), item) + itemByKey.set(input.getKey(item), item); } - const prunedOrder: string[] = [] - const seen = new Set() + const prunedOrder: string[] = []; + const seen = new Set(); for (const key of input.storedOrder) { if (!itemByKey.has(key) || seen.has(key)) { - continue + continue; } - seen.add(key) - prunedOrder.push(key) + seen.add(key); + prunedOrder.push(key); } if (prunedOrder.length === 0) { - return input.items + return input.items; } - const orderedSet = new Set(prunedOrder) - const ordered: T[] = [] - let orderedIndex = 0 + const orderedSet = new Set(prunedOrder); + const ordered: T[] = []; + let orderedIndex = 0; for (const item of input.items) { - const key = input.getKey(item) + const key = input.getKey(item); if (!orderedSet.has(key)) { - ordered.push(item) - continue + ordered.push(item); + continue; } - const targetKey = prunedOrder[orderedIndex] ?? key - orderedIndex += 1 - ordered.push(itemByKey.get(targetKey) ?? item) + const targetKey = prunedOrder[orderedIndex] ?? key; + orderedIndex += 1; + ordered.push(itemByKey.get(targetKey) ?? item); } - return ordered + return ordered; } export function appendMissingOrderKeys(input: { - currentOrder: string[] - visibleKeys: string[] + currentOrder: string[]; + visibleKeys: string[]; }): string[] { if (input.visibleKeys.length === 0) { - return input.currentOrder + return input.currentOrder; } - const existingKeys = new Set(input.currentOrder) - const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)) + const existingKeys = new Set(input.currentOrder); + const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key)); if (missingKeys.length === 0) { - return input.currentOrder + return input.currentOrder; } - return [...input.currentOrder, ...missingKeys] + return [...input.currentOrder, ...missingKeys]; } function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string { - return `${serverId.trim()}::${projectKey.trim()}` + return `${serverId.trim()}::${projectKey.trim()}`; } function toWorkspaceDescriptor(payload: { - id: string - projectId: string - projectDisplayName: string - projectRootPath: string - projectKind: WorkspaceDescriptor['projectKind'] - workspaceKind: WorkspaceDescriptor['workspaceKind'] - name: string - status: WorkspaceDescriptor['status'] - activityAt: string | null + id: string; + projectId: string; + projectDisplayName: string; + projectRootPath: string; + projectKind: WorkspaceDescriptor["projectKind"]; + workspaceKind: WorkspaceDescriptor["workspaceKind"]; + name: string; + status: WorkspaceDescriptor["status"]; + activityAt: string | null; }): WorkspaceDescriptor { - return normalizeWorkspaceDescriptor(payload) + return normalizeWorkspaceDescriptor(payload); } export function useSidebarWorkspacesList(options?: { - serverId?: string | null - enabled?: boolean + serverId?: string | null; + enabled?: boolean; }): SidebarWorkspacesListResult { - const runtime = getHostRuntimeStore() + const runtime = getHostRuntimeStore(); const serverId = useMemo(() => { - const value = options?.serverId - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null - }, [options?.serverId]) + const value = options?.serverId; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; + }, [options?.serverId]); const persistedProjectOrder = useSidebarOrderStore((state) => - serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER - ) + serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER, + ); const persistedWorkspaceOrderByScope = useSidebarOrderStore((state) => - serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE - ) + serverId ? state.workspaceOrderByServerAndProject : EMPTY_WORKSPACE_ORDER_SCOPE, + ); - const isActive = Boolean(serverId) + const isActive = Boolean(serverId); const sessionWorkspaces = useSessionStore((state) => - isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null - ) - const hasHydratedWorkspaces = useSessionStore( - (state) => (isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false) - ) + isActive && serverId ? (state.sessions[serverId]?.workspaces ?? null) : null, + ); + const hasHydratedWorkspaces = useSessionStore((state) => + isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, + ); const connectionStatus = useSyncExternalStore( (onStoreChange) => isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {}, () => { if (!isActive || !serverId) { - return 'idle' + return "idle"; } - const snapshot = runtime.getSnapshot(serverId) - return snapshot?.connectionStatus ?? 'idle' + const snapshot = runtime.getSnapshot(serverId); + return snapshot?.connectionStatus ?? "idle"; }, () => { if (!isActive || !serverId) { - return 'idle' + return "idle"; } - const snapshot = runtime.getSnapshot(serverId) - return snapshot?.connectionStatus ?? 'idle' - } - ) + const snapshot = runtime.getSnapshot(serverId); + return snapshot?.connectionStatus ?? "idle"; + }, + ); const projects = useMemo(() => { if (!sessionWorkspaces || sessionWorkspaces.size === 0 || !serverId) { - return EMPTY_PROJECTS + return EMPTY_PROJECTS; } return buildSidebarProjectsFromWorkspaces({ serverId, workspaces: sessionWorkspaces.values(), projectOrder: persistedProjectOrder, workspaceOrderByScope: persistedWorkspaceOrderByScope, - }) - }, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]) + }); + }, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]); useEffect(() => { if (!serverId || projects.length === 0) { - return + return; } const nextProjectOrder = appendMissingOrderKeys({ currentOrder: persistedProjectOrder, visibleKeys: projects.map((project) => project.projectKey), - }) + }); if (nextProjectOrder !== persistedProjectOrder) { - useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder) + useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder); } for (const project of projects) { - const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey) + const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey); const persistedWorkspaceOrder = - persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER + persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER; const nextWorkspaceOrder = appendMissingOrderKeys({ currentOrder: persistedWorkspaceOrder, visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey), - }) + }); if (nextWorkspaceOrder !== persistedWorkspaceOrder) { useSidebarOrderStore .getState() - .setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder) + .setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder); } } - }, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]) + }, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId]); const refreshAll = useCallback(() => { - if (!isActive || !serverId || connectionStatus !== 'online') { - return + if (!isActive || !serverId || connectionStatus !== "online") { + return; } - const client = runtime.getClient(serverId) + const client = runtime.getClient(serverId); if (!client) { - return + return; } void (async () => { - const next = new Map() - let cursor: string | null = null + const next = new Map(); + let cursor: string | null = null; try { while (true) { const payload = await client.fetchWorkspaces({ - sort: [{ key: 'activity_at', direction: 'desc' }], + sort: [{ key: "activity_at", direction: "desc" }], page: cursor ? { limit: 200, cursor } : { limit: 200 }, - }) + }); for (const entry of payload.entries) { - const workspace = toWorkspaceDescriptor(entry) - next.set(workspace.id, workspace) + const workspace = toWorkspaceDescriptor(entry); + next.set(workspace.id, workspace); } if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { - break + break; } - cursor = payload.pageInfo.nextCursor + cursor = payload.pageInfo.nextCursor; } - const store = useSessionStore.getState() - store.setWorkspaces(serverId, next) - store.setHasHydratedWorkspaces(serverId, true) + const store = useSessionStore.getState(); + store.setWorkspaces(serverId, next); + store.setHasHydratedWorkspaces(serverId, true); } catch { // ignore explicit refresh failures; hook keeps existing data } - })() - }, [connectionStatus, isActive, runtime, serverId]) + })(); + }, [connectionStatus, isActive, runtime, serverId]); - const isLoading = isActive && Boolean(serverId) && connectionStatus === 'online' && !hasHydratedWorkspaces - const isInitialLoad = isLoading && projects.length === 0 - const isRevalidating = false + const isLoading = + isActive && Boolean(serverId) && connectionStatus === "online" && !hasHydratedWorkspaces; + const isInitialLoad = isLoading && projects.length === 0; + const isRevalidating = false; return { projects, @@ -388,5 +392,5 @@ export function useSidebarWorkspacesList(options?: { isInitialLoad, isRevalidating, refreshAll, - } + }; } diff --git a/packages/app/src/hooks/use-theme-color.ts b/packages/app/src/hooks/use-theme-color.ts index 0cbc3a6e6..36b012ca9 100644 --- a/packages/app/src/hooks/use-theme-color.ts +++ b/packages/app/src/hooks/use-theme-color.ts @@ -3,14 +3,14 @@ * https://docs.expo.dev/guides/color-schemes/ */ -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; +import { Colors } from "@/constants/theme"; +import { useColorScheme } from "@/hooks/use-color-scheme"; export function useThemeColor( props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark + colorName: keyof typeof Colors.light & keyof typeof Colors.dark, ) { - const theme = useColorScheme() ?? 'light'; + const theme = useColorScheme() ?? "light"; const colorFromProps = props[theme]; if (colorFromProps) { diff --git a/packages/app/src/hooks/use-workspace-navigation.ts b/packages/app/src/hooks/use-workspace-navigation.ts index 09c46c54d..2cf8017aa 100644 --- a/packages/app/src/hooks/use-workspace-navigation.ts +++ b/packages/app/src/hooks/use-workspace-navigation.ts @@ -16,11 +16,8 @@ export function navigateToWorkspace(serverId: string, workspaceId: string) { export function useWorkspaceNavigation() { return { - navigateToWorkspace: useCallback( - (serverId: string, workspaceId: string) => { - navigateToWorkspace(serverId, workspaceId); - }, - [] - ), + navigateToWorkspace: useCallback((serverId: string, workspaceId: string) => { + navigateToWorkspace(serverId, workspaceId); + }, []), }; } diff --git a/packages/app/src/keyboard/focus-scope.ts b/packages/app/src/keyboard/focus-scope.ts index b6d36d960..457db7e3b 100644 --- a/packages/app/src/keyboard/focus-scope.ts +++ b/packages/app/src/keyboard/focus-scope.ts @@ -9,10 +9,7 @@ export function resolveKeyboardFocusScope(input: { return commandCenterOpen ? "command-center" : "other"; } - if ( - target.closest("[data-testid='terminal-surface']") || - target.closest(".xterm") - ) { + if (target.closest("[data-testid='terminal-surface']") || target.closest(".xterm")) { return "terminal"; } diff --git a/packages/app/src/keyboard/keyboard-action-dispatcher.ts b/packages/app/src/keyboard/keyboard-action-dispatcher.ts index 67a26af9b..6c6dd0fa9 100644 --- a/packages/app/src/keyboard/keyboard-action-dispatcher.ts +++ b/packages/app/src/keyboard/keyboard-action-dispatcher.ts @@ -1,8 +1,4 @@ -export type KeyboardActionScope = - | "global" - | "message-input" - | "sidebar" - | "workspace"; +export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace"; export type KeyboardActionId = | "message-input.focus" diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index c3dd85c99..2e1d0a6a6 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -19,7 +19,7 @@ function keyboardEvent(overrides: Partial): KeyboardEvent { } function shortcutContext( - overrides: Partial = {} + overrides: Partial = {}, ): KeyboardShortcutContext { return { isMac: false, @@ -260,7 +260,14 @@ describe("keyboard-shortcuts", () => { }, ]; - it.each(matchingCases)("$name", ({ event, context, action, payload, preventDefault, stopPropagation }) => { + it.each(matchingCases)("$name", ({ + event, + context, + action, + payload, + preventDefault, + stopPropagation, + }) => { expectShortcutResolution({ event, context, @@ -334,10 +341,7 @@ describe("keyboard-shortcuts", () => { }); describe("keyboard-shortcut help sections", () => { - function findRow( - sections: ReturnType, - id: string - ) { + function findRow(sections: ReturnType, id: string) { for (const section of sections) { const row = section.rows.find((candidate) => candidate.id === id); if (row) { diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index a43d0c2d0..4bdd33e9f 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -97,10 +97,7 @@ interface ShortcutBinding { // --- Constants --- -const SHORTCUT_HELP_SECTION_TITLES: Record< - KeyboardShortcutHelpSection["id"], - string -> = { +const SHORTCUT_HELP_SECTION_TITLES: Record = { global: "Global", "agent-input": "Agent Input", }; @@ -724,32 +721,24 @@ function matchesCombo(combo: KeyCombo, event: KeyboardEvent): boolean { } const codeMatch = event.code === combo.code; - const keyMatch = - combo.key !== undefined && - event.key.toLowerCase() === combo.key.toLowerCase(); + const keyMatch = combo.key !== undefined && event.key.toLowerCase() === combo.key.toLowerCase(); return codeMatch || keyMatch; } -function matchesWhen( - when: ShortcutWhen | undefined, - context: KeyboardShortcutContext -): boolean { +function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutContext): boolean { if (!when) return true; if (when.mac !== undefined && when.mac !== context.isMac) return false; - if (when.desktop !== undefined && when.desktop !== context.isDesktop) - return false; - if (when.terminal === false && context.focusScope === "terminal") - return false; + if (when.desktop !== undefined && when.desktop !== context.isDesktop) return false; + if (when.terminal === false && context.focusScope === "terminal") return false; if (when.commandCenter === false && context.commandCenterOpen) return false; if (when.hasSelectedAgent === true && !context.hasSelectedAgent) return false; - if (when.focusScope !== undefined && context.focusScope !== when.focusScope) - return false; + if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false; return true; } function resolvePayload( def: ShortcutPayloadDef | undefined, - event: KeyboardEvent + event: KeyboardEvent, ): KeyboardShortcutPayload { if (!def) return null; switch (def.type) { @@ -766,11 +755,10 @@ function resolvePayload( function helpMatchesPlatform( when: ShortcutWhen | undefined, - context: KeyboardShortcutPlatformContext + context: KeyboardShortcutPlatformContext, ): boolean { if (when?.mac !== undefined && when.mac !== context.isMac) return false; - if (when?.desktop !== undefined && when.desktop !== context.isDesktop) - return false; + if (when?.desktop !== undefined && when.desktop !== context.isDesktop) return false; return true; } @@ -799,13 +787,10 @@ export function resolveKeyboardShortcut(input: { } export function buildKeyboardShortcutHelpSections( - input: KeyboardShortcutPlatformContext + input: KeyboardShortcutPlatformContext, ): KeyboardShortcutHelpSection[] { const seenRows = new Set(); - const rowsBySection = new Map< - KeyboardShortcutHelpSection["id"], - KeyboardShortcutHelpRow[] - >([ + const rowsBySection = new Map([ ["global", []], ["agent-input", []], ]); @@ -836,10 +821,7 @@ export function buildKeyboardShortcutHelpSections( }); } - const sectionOrder: KeyboardShortcutHelpSection["id"][] = [ - "global", - "agent-input", - ]; + const sectionOrder: KeyboardShortcutHelpSection["id"][] = ["global", "agent-input"]; return sectionOrder.flatMap((sectionId) => { const rows = rowsBySection.get(sectionId) ?? []; diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 0a45a4a55..cbc792264 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -32,14 +32,8 @@ import { useHosts, type HostRuntimeConnectionStatus, } from "@/runtime/host-runtime"; -import { - getInitDeferred, - getInitKey, -} from "@/utils/agent-initialization"; -import { - derivePendingPermissionKey, - normalizeAgentSnapshot, -} from "@/utils/agent-snapshots"; +import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { useCreateFlowStore } from "@/stores/create-flow-store"; @@ -81,10 +75,10 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string function useAgentPanelDescriptor( target: { kind: "agent"; agentId: string }, - context: { serverId: string } + context: { serverId: string }, ): PanelDescriptor { const agent = useSessionStore( - (state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null + (state) => state.sessions[context.serverId]?.agents?.get(target.agentId) ?? null, ); const provider = agent?.provider ?? "codex"; const label = resolveWorkspaceAgentTabLabel(agent?.title); @@ -113,7 +107,7 @@ function AgentPanel() { (input: { filePath: string }) => { openFileInWorkspace(input.filePath); }, - [openFileInWorkspace] + [openFileInWorkspace], ); return ( = { const EMPTY_STREAM_ITEMS: StreamItem[] = []; -function logWebStickyBottom( - _event: string, - _details: Record -): void {} +function logWebStickyBottom(_event: string, _details: Record): void {} function toErrorMessage(error: unknown): string { if (error instanceof Error) { @@ -171,10 +162,9 @@ function AgentPanelContent({ const connectionServerId = resolvedServerId ?? null; const daemon = connectionServerId - ? daemons.find((entry) => entry.serverId === connectionServerId) ?? null + ? (daemons.find((entry) => entry.serverId === connectionServerId) ?? null) : null; - const serverLabel = - daemon?.label ?? connectionServerId ?? "Selected host"; + const serverLabel = daemon?.label ?? connectionServerId ?? "Selected host"; const isUnknownDaemon = Boolean(connectionServerId && !daemon); const connectionStatus: HostRuntimeConnectionStatus = isUnknownDaemon && runtimeConnectionStatus === "connecting" @@ -240,61 +230,47 @@ function AgentPanelBody({ buildDraftStoreKey({ serverId, agentId: agentId ?? "__pending__", - }) + }), ); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { addImagesRef.current?.(files); }, []); - const handleAddImagesCallback = useCallback( - (addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages; - }, - [] - ); + const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, []); const agent = useSessionStore((state) => - agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined + agentId ? state.sessions[serverId]?.agents?.get(agentId) : undefined, ); const streamItemsRaw = useSessionStore((state) => - agentId - ? state.sessions[serverId]?.agentStreamTail?.get(agentId) - : undefined + agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, ); const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); - const markPendingCreateLifecycle = useCreateFlowStore( - (state) => state.markLifecycle - ); + const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle); const clearPendingCreate = useCreateFlowStore((state) => state.clear); const isInitializingFromMap = useSessionStore((state) => - agentId - ? state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false - : false + agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false, ); const historySyncGeneration = useSessionStore( - (state) => state.sessions[serverId]?.historySyncGeneration ?? 0 + (state) => state.sessions[serverId]?.historySyncGeneration ?? 0, ); const hasAppliedAuthoritativeHistory = useSessionStore((state) => agentId - ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === - true - : false + ? state.sessions[serverId]?.agentAuthoritativeHistoryApplied?.get(agentId) === true + : false, ); const agentHistorySyncGeneration = useSessionStore((state) => - agentId - ? state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1 - : -1 + agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, ); const allPendingPermissions = useSessionStore( - (state) => state.sessions[serverId]?.pendingPermissions + (state) => state.sessions[serverId]?.pendingPermissions, ); const setAgents = useSessionStore((state) => state.setAgents); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setPendingPermissions = useSessionStore( - (state) => state.setPendingPermissions - ); + const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const { ensureAgentIsInitialized } = useAgentInitialization({ serverId, @@ -379,9 +355,7 @@ function AgentPanelBody({ } if (changed.length > 0 && Object.keys(prev).length > 0) { console.log("[AgentPanelBody] values changed:", changed.join(", "), { - changed: Object.fromEntries( - changed.map((k) => [k, { prev: prev[k], curr: curr[k] }]) - ), + changed: Object.fromEntries(changed.map((k) => [k, { prev: prev[k], curr: curr[k] }])), }); } debugPrevRef.current = curr; @@ -409,7 +383,7 @@ function AgentPanelBody({ return { kind: "error", message }; }); }, - [agentId] + [agentId], ); const ensureInitializedWithSyncErrorHandling = useCallback( @@ -421,7 +395,7 @@ function AgentPanelBody({ handleHistorySyncFailure({ origin, error }); }); }, - [agentId, ensureAgentIsInitialized, handleHistorySyncFailure] + [agentId, ensureAgentIsInitialized, handleHistorySyncFailure], ); useEffect(() => { @@ -446,18 +420,9 @@ function AgentPanelBody({ return; } ensureInitializedWithSyncErrorHandling("focus"); - }, [ - agentId, - ensureInitializedWithSyncErrorHandling, - hasSession, - isConnected, - isPaneFocused, - ]); + }, [agentId, ensureInitializedWithSyncErrorHandling, hasSession, isConnected, isPaneFocused]); - const isArchivingCurrentAgent = Boolean( - agentId && - isArchivingAgent({ serverId, agentId }) - ); + const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); useEffect(() => { if (wasPaneFocusedRef.current && !isPaneFocused) { @@ -515,21 +480,18 @@ function AgentPanelBody({ return streamItems; } const alreadyHasOptimistic = streamItems.some( - (item) => item.kind === "user_message" && item.id === optimistic.id + (item) => item.kind === "user_message" && item.id === optimistic.id, ); return alreadyHasOptimistic ? streamItems : [...optimisticStreamItems, ...streamItems]; }, [optimisticStreamItems, streamItems]); - const shouldUseOptimisticStream = - isPendingCreateForPanel && optimisticStreamItems.length > 0; + const shouldUseOptimisticStream = isPendingCreateForPanel && optimisticStreamItems.length > 0; const authoritativeStatus = agent?.status; const isAuthoritativeBootstrapping = authoritativeStatus === "initializing" || authoritativeStatus === "idle"; const showPendingCreateSubmitLoading = - isPendingCreateForPanel && - (!authoritativeStatus || isAuthoritativeBootstrapping); - const canFinalizePendingCreate = - Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; + isPendingCreateForPanel && (!authoritativeStatus || isAuthoritativeBootstrapping); + const canFinalizePendingCreate = Boolean(authoritativeStatus) && !isAuthoritativeBootstrapping; const placeholderAgent: Agent | null = useMemo(() => { if (!shouldUseOptimisticStream || !agentId) { @@ -598,7 +560,7 @@ function AgentPanelBody({ intent: routeBottomAnchorRequestRef.current, effectiveAgentId: effectiveAgent?.id ?? null, }), - [effectiveAgent?.id] + [effectiveAgent?.id], ); useEffect(() => { @@ -606,16 +568,10 @@ function AgentPanelBody({ return; } const hasUserMessage = streamItems.some( - (item) => - item.kind === "user_message" && - item.id === pendingCreate.clientMessageId + (item) => item.kind === "user_message" && item.id === pendingCreate.clientMessageId, ); if (hasUserMessage && canFinalizePendingCreate) { - if ( - agentId && - pendingCreate.images && - pendingCreate.images.length > 0 - ) { + if (agentId && pendingCreate.images && pendingCreate.images.length > 0) { setAgentStreamTail(serverId, (previous) => { const current = previous.get(agentId); if (!current) { @@ -705,10 +661,7 @@ function AgentPanelBody({ if (attemptToken !== initAttemptTokenRef.current) { return; } - const currentAgent = useSessionStore - .getState() - .sessions[serverId] - ?.agents.get(agentId); + const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId); if (!currentAgent) { const result = await client.fetchAgent(agentId); if (attemptToken !== initAttemptTokenRef.current) { @@ -830,23 +783,16 @@ function AgentPanelBody({ return ( - + - + Archiving agent... - - Please wait while we archive this agent. - + Please wait while we archive this agent. ) : null} @@ -932,12 +876,10 @@ function AgentSessionUnavailableState({ - Cannot open this agent because {serverLabel} is not configured on - this device. + Cannot open this agent because {serverLabel} is not configured on this device. - Add the host in Settings or open an agent on a configured server to - continue. + Add the host in Settings or open an agent on a configured server to continue. @@ -966,15 +908,11 @@ function AgentSessionUnavailableState({ ) : ( <> - - Reconnecting to {serverLabel}... - + Reconnecting to {serverLabel}... We will show this agent again as soon as the host is reachable. - {lastError ? ( - {lastError} - ) : null} + {lastError ? {lastError} : null} )} diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index 499a7b8cd..e96ea9ef6 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -17,14 +17,8 @@ function useDraftPanelDescriptor() { } function DraftPanel() { - const { - serverId, - workspaceId, - tabId, - target, - openFileInWorkspace, - retargetCurrentTab, - } = usePaneContext(); + const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = + usePaneContext(); invariant(target.kind === "draft", "DraftPanel requires draft target"); return ( diff --git a/packages/app/src/panels/file-panel.tsx b/packages/app/src/panels/file-panel.tsx index c02bbc117..4080e8530 100644 --- a/packages/app/src/panels/file-panel.tsx +++ b/packages/app/src/panels/file-panel.tsx @@ -18,13 +18,7 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) { function FilePanel() { const { serverId, workspaceId, target } = usePaneContext(); invariant(target.kind === "file", "FilePanel requires file target"); - return ( - - ); + return ; } export const filePanelRegistration: PanelRegistration<"file"> = { diff --git a/packages/app/src/panels/panel-registry.ts b/packages/app/src/panels/panel-registry.ts index 7f4de2835..3bea9167b 100644 --- a/packages/app/src/panels/panel-registry.ts +++ b/packages/app/src/panels/panel-registry.ts @@ -27,24 +27,24 @@ export interface PanelRegistration< component: ComponentType; useDescriptor( target: Extract, - context: PanelDescriptorContext + context: PanelDescriptorContext, ): PanelDescriptor; confirmClose?( target: Extract, - context: PanelDescriptorContext + context: PanelDescriptorContext, ): Promise; } const panelRegistry = new Map(); export function registerPanel( - registration: PanelRegistration + registration: PanelRegistration, ): void { panelRegistry.set(registration.kind, registration as unknown as PanelRegistration); } export function getPanelRegistration( - kind: WorkspaceTabTarget["kind"] + kind: WorkspaceTabTarget["kind"], ): PanelRegistration | undefined { return panelRegistry.get(kind); } diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 63a8525d1..11c800f1c 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -21,7 +21,7 @@ function trimNonEmpty(value: string | null | undefined): string | null { function useTerminalPanelDescriptor( target: { kind: "terminal"; terminalId: string }, - context: { serverId: string; workspaceId: string } + context: { serverId: string; workspaceId: string }, ): PanelDescriptor { const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); const terminalsQuery = useQuery({ diff --git a/packages/app/src/polyfills/screen-orientation.ts b/packages/app/src/polyfills/screen-orientation.ts index c623c74ed..515d7184c 100644 --- a/packages/app/src/polyfills/screen-orientation.ts +++ b/packages/app/src/polyfills/screen-orientation.ts @@ -1,18 +1,12 @@ export function polyfillScreenOrientation() { - if ( - typeof window === "undefined" || - typeof screen === "undefined" || - screen.orientation - ) { + if (typeof window === "undefined" || typeof screen === "undefined" || screen.orientation) { return; } Object.defineProperty(screen, "orientation", { value: { get type() { - return window.innerWidth > window.innerHeight - ? "landscape-primary" - : "portrait-primary"; + return window.innerWidth > window.innerHeight ? "landscape-primary" : "portrait-primary"; }, get angle() { return 0; diff --git a/packages/app/src/query/query-client.ts b/packages/app/src/query/query-client.ts index dc97bb935..4be76c73c 100644 --- a/packages/app/src/query/query-client.ts +++ b/packages/app/src/query/query-client.ts @@ -11,4 +11,3 @@ export const queryClient = new QueryClient({ }, }, }); - diff --git a/packages/app/src/runtime/activity/last-activity-scheduler.ts b/packages/app/src/runtime/activity/last-activity-scheduler.ts index a1110e85c..1e4ac682a 100644 --- a/packages/app/src/runtime/activity/last-activity-scheduler.ts +++ b/packages/app/src/runtime/activity/last-activity-scheduler.ts @@ -4,7 +4,7 @@ const DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS = 16; export function scheduleAgentLastActivityFlush( callback: () => void, - fallbackIntervalMs = DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS + fallbackIntervalMs = DEFAULT_ACTIVITY_FLUSH_INTERVAL_MS, ): ActivityFlushHandle { if (typeof requestAnimationFrame === "function") { const handle = requestAnimationFrame(callback); diff --git a/packages/app/src/runtime/activity/types.ts b/packages/app/src/runtime/activity/types.ts index 59371add3..805b25b92 100644 --- a/packages/app/src/runtime/activity/types.ts +++ b/packages/app/src/runtime/activity/types.ts @@ -1,8 +1,6 @@ export type AgentLastActivityUpdates = Map; -export type AgentLastActivityCommitter = ( - updates: AgentLastActivityUpdates -) => void; +export type AgentLastActivityCommitter = (updates: AgentLastActivityUpdates) => void; export interface ActivityFlushHandle { cancel: () => void; diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index ba91f8777..60c5fb0de 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -44,9 +44,7 @@ class FakeDaemonClient { return this.state; } - subscribeConnectionStatus( - listener: (status: ConnectionState) => void - ): () => void { + subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { this.listeners.add(listener); listener(this.state); return () => { @@ -59,7 +57,7 @@ class FakeDaemonClient { } async fetchAgents( - options?: FetchAgentsOptions + options?: FetchAgentsOptions, ): Promise>> { this.fetchAgentsCalls.push(options ?? {}); const queued = this.fetchAgentsResponses.shift(); @@ -146,8 +144,7 @@ function makeFetchAgentsEntry(input: { thinkingOptionId: null, requiresAttention: input.requiresAttention ?? false, attentionReason: input.attentionReason ?? null, - attentionTimestamp: - input.requiresAttention && input.attentionReason ? input.updatedAt : null, + attentionTimestamp: input.requiresAttention && input.attentionReason ? input.updatedAt : null, archivedAt: null, labels: {}, }, @@ -192,7 +189,7 @@ function makeHost(input?: Partial): HostProfile { function makeDeps( latencyByConnectionId: Record, - createdClients: FakeDaemonClient[] + createdClients: FakeDaemonClient[], ): HostRuntimeControllerDeps { return { createClient: () => { @@ -364,9 +361,7 @@ describe("HostRuntimeController", () => { throw new Error("should adopt the probe client"); }, connectToDaemon: async ({ host, connection }) => { - const client = makeConnectedProbeClient( - connection.id === "direct:lan:6767" ? 12 : 30 - ); + const client = makeConnectedProbeClient(connection.id === "direct:lan:6767" ? 12 : 30); if (connection.id === "relay:relay.paseo.sh:443") { client.ping = async () => ({ rttMs: await slowPing.promise }); } @@ -460,9 +455,7 @@ describe("HostRuntimeController", () => { for (let index = 0; index < 6 && !switched; index += 1) { clearProbeBackoff(controller); await controller.runProbeCycleNow(); - switched = - controller.getSnapshot().activeConnectionId === - "relay:relay.paseo.sh:443"; + switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443"; } expect(switched).toBe(true); expect(controller.getSnapshot().client).not.toBeNull(); @@ -509,9 +502,7 @@ describe("HostRuntimeController", () => { for (let index = 0; index < 6 && !switched; index += 1) { clearProbeBackoff(controller); await controller.runProbeCycleNow(); - switched = - controller.getSnapshot().activeConnectionId === - "relay:relay.paseo.sh:443"; + switched = controller.getSnapshot().activeConnectionId === "relay:relay.paseo.sh:443"; } expect(switched).toBe(true); }); @@ -566,7 +557,7 @@ describe("HostRuntimeController", () => { { "direct:lan:6767": 12, }, - clients + clients, ), }); @@ -579,8 +570,7 @@ describe("HostRuntimeController", () => { const transitionPayloads = infoSpy.mock.calls .filter((call) => call[0] === "[HostRuntimeTransition]") .map((call) => call[1] as { reasonCode?: string | null }); - const lastTransition = - transitionPayloads[transitionPayloads.length - 1] ?? null; + const lastTransition = transitionPayloads[transitionPayloads.length - 1] ?? null; expect(lastTransition?.reasonCode).toBe("transport_error"); } finally { @@ -731,10 +721,7 @@ describe("HostRuntimeController", () => { deps, }); - const waitUntil = async ( - predicate: () => boolean, - timeoutMs = 200 - ): Promise => { + const waitUntil = async (predicate: () => boolean, timeoutMs = 200): Promise => { const timeoutAt = Date.now() + timeoutMs; while (!predicate()) { if (Date.now() >= timeoutAt) { @@ -830,9 +817,7 @@ describe("HostRuntimeController", () => { fastProbe.resolve(12); await second; - const probeAfterSecond = controller - .getSnapshot() - .probeByConnectionId.get("direct:lan:6767"); + const probeAfterSecond = controller.getSnapshot().probeByConnectionId.get("direct:lan:6767"); expect(probeAfterSecond).toEqual({ status: "available", latencyMs: 12, @@ -888,9 +873,7 @@ describe("HostRuntimeController", () => { clearProbeBackoff(controller); await controller.runProbeCycleNow(); expect(controller.getSnapshot().client).toBe(activeClientBeforeProbes); - expect(controller.getSnapshot().clientGeneration).toBe( - generationBeforeProbes - ); + expect(controller.getSnapshot().clientGeneration).toBe(generationBeforeProbes); expect(createdClients).toHaveLength(0); }); }); @@ -920,10 +903,9 @@ describe("HostRuntimeStore", () => { }, }); - useSessionStore.getState().initializeSession( - host.serverId, - fakeClient as unknown as DaemonClient - ); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); store.syncHosts([host]); const timeoutAt = Date.now() + 200; @@ -1029,7 +1011,7 @@ describe("HostRuntimeStore", () => { }), ], hasMore: false, - }) + }), ); const store = new HostRuntimeStore({ deps: { @@ -1043,17 +1025,13 @@ describe("HostRuntimeStore", () => { }, }); - useSessionStore.getState().initializeSession( - host.serverId, - fakeClient as unknown as DaemonClient - ); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); store.syncHosts([host]); const timeoutAt = Date.now() + 300; - while ( - fakeClient.fetchAgentsCalls.length < 2 && - Date.now() < timeoutAt - ) { + while (fakeClient.fetchAgentsCalls.length < 2 && Date.now() < timeoutAt) { await new Promise((resolve) => setTimeout(resolve, 0)); } @@ -1071,16 +1049,14 @@ describe("HostRuntimeStore", () => { }); let staleAgent = - useSessionStore.getState().sessions[host.serverId]?.agents?.get( - "agent-stale-attention" - ) ?? null; + useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? + null; const staleTimeoutAt = Date.now() + 300; while (!staleAgent && Date.now() < staleTimeoutAt) { await new Promise((resolve) => setTimeout(resolve, 0)); staleAgent = - useSessionStore.getState().sessions[host.serverId]?.agents?.get( - "agent-stale-attention" - ) ?? null; + useSessionStore.getState().sessions[host.serverId]?.agents?.get("agent-stale-attention") ?? + null; } expect(staleAgent?.requiresAttention).toBe(true); expect(staleAgent?.attentionReason).toBe("error"); @@ -1118,10 +1094,9 @@ describe("HostRuntimeStore", () => { }, }); - useSessionStore.getState().initializeSession( - host.serverId, - fakeClient as unknown as DaemonClient - ); + useSessionStore + .getState() + .initializeSession(host.serverId, fakeClient as unknown as DaemonClient); store.syncHosts([host]); const initialTimeoutAt = Date.now() + 200; diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 42f1c5fe1..f4cca9932 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -15,15 +15,9 @@ import { } from "@/types/host-connection"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { ConnectionOfferSchema, type ConnectionOffer } from "@server/shared/connection-offer"; -import { - shouldUseDesktopDaemon, - startDesktopDaemon, -} from "@/desktop/daemon/desktop-daemon"; +import { shouldUseDesktopDaemon, startDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { connectToDaemon } from "@/utils/test-daemon-connection"; -import { - buildDaemonWebSocketUrl, - buildRelayWebSocketUrl, -} from "@/utils/daemon-endpoints"; +import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@/utils/daemon-endpoints"; import { getOrCreateClientId } from "@/utils/client-id"; import { selectBestConnection, @@ -37,12 +31,7 @@ import { import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore, type Agent } from "@/stores/session-store"; -export type HostRuntimeConnectionStatus = - | "idle" - | "connecting" - | "online" - | "offline" - | "error"; +export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error"; export type ActiveConnection = | { type: "directTcp"; endpoint: string; display: string } @@ -73,15 +62,11 @@ export type HostRuntimeSnapshot = { clientGeneration: number; }; -export function isHostRuntimeConnected( - snapshot: HostRuntimeSnapshot | null -): boolean { +export function isHostRuntimeConnected(snapshot: HostRuntimeSnapshot | null): boolean { return snapshot?.connectionStatus === "online"; } -export function isHostRuntimeDirectoryLoading( - snapshot: HostRuntimeSnapshot | null -): boolean { +export function isHostRuntimeDirectoryLoading(snapshot: HostRuntimeSnapshot | null): boolean { if (!snapshot) { return true; } @@ -93,8 +78,7 @@ export function isHostRuntimeDirectoryLoading( } return ( !snapshot.hasEverLoadedAgentDirectory && - (snapshot.connectionStatus === "connecting" || - snapshot.connectionStatus === "online") + (snapshot.connectionStatus === "connecting" || snapshot.connectionStatus === "online") ); } @@ -117,10 +101,7 @@ export type HostRuntimeControllerDeps = { clientId: string; runtimeGeneration: number; }) => DaemonClient; - connectToDaemon: (input: { - host: HostProfile; - connection: HostConnection; - }) => Promise<{ + connectToDaemon: (input: { host: HostProfile; connection: HostConnection }) => Promise<{ client: DaemonClient; serverId: string; hostname: string | null; @@ -148,7 +129,7 @@ const DEFAULT_AGENT_DIRECTORY_SORT: NonNullable = [ ]; function readFetchAgentsHasMore( - pageInfo: Awaited>["pageInfo"] + pageInfo: Awaited>["pageInfo"], ): boolean { const page = pageInfo as { hasMore?: boolean; @@ -164,7 +145,7 @@ function readFetchAgentsHasMore( } function readFetchAgentsNextCursor( - pageInfo: Awaited>["pageInfo"] + pageInfo: Awaited>["pageInfo"], ): string | null { const page = pageInfo as { nextCursor?: string | null; @@ -347,7 +328,7 @@ function nextConnectionMachineState(input: { } function toSnapshotConnectionPatch( - state: HostRuntimeConnectionMachineState + state: HostRuntimeConnectionMachineState, ): Pick< HostRuntimeSnapshot, "activeConnectionId" | "activeConnection" | "connectionStatus" | "lastError" | "lastOnlineAt" @@ -404,10 +385,7 @@ function buildConnectionCandidates(host: HostProfile): ConnectionCandidate[] { })); } -function findConnectionById( - host: HostProfile, - connectionId: string | null -): HostConnection | null { +function findConnectionById(host: HostProfile, connectionId: string | null): HostConnection | null { if (!connectionId) { return null; } @@ -417,7 +395,7 @@ function findConnectionById( function probeIntervalForConnection( firstSeenAt: number, isActiveOnline: boolean, - now: number + now: number, ): number { if (isActiveOnline) { return PROBE_STEADY_MS; @@ -583,9 +561,7 @@ export class HostRuntimeController { } markAgentDirectorySyncLoading(): void { - const status = this.snapshot.hasEverLoadedAgentDirectory - ? "revalidating" - : "initial_loading"; + const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading"; this.updateSnapshot({ agentDirectoryStatus: status, agentDirectoryError: null, @@ -687,7 +663,7 @@ export class HostRuntimeController { const maybeActivateFirstAvailable = async ( connectionId: string, - client: DaemonClient + client: DaemonClient, ): Promise => { while (!this.snapshot.activeConnectionId) { if (!activationLock) { @@ -716,10 +692,7 @@ export class HostRuntimeController { ? probeByConnectionId.get(currentActiveConnectionId) : null; - if ( - !currentActiveConnectionId || - !findConnectionById(this.host, currentActiveConnectionId) - ) { + if (!currentActiveConnectionId || !findConnectionById(this.host, currentActiveConnectionId)) { const nextConnectionId = selectBestConnection({ candidates: buildConnectionCandidates(this.host), probeByConnectionId, @@ -755,10 +728,8 @@ export class HostRuntimeController { const available = Array.from(probeByConnectionId.entries()) .filter( - ( - entry - ): entry is [string, Extract] => - entry[1].status === "available" + (entry): entry is [string, Extract] => + entry[1].status === "available", ) .map(([connectionId, probe]) => ({ connectionId, @@ -787,9 +758,7 @@ export class HostRuntimeController { this.switchCandidateHitCount = 1; } - if ( - this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES - ) { + if (this.switchCandidateHitCount >= ADAPTIVE_SWITCH_CONSECUTIVE_PROBES) { this.switchCandidateConnectionId = null; this.switchCandidateHitCount = 0; await this.switchToConnection({ @@ -857,7 +826,7 @@ export class HostRuntimeController { } private updateSnapshot( - patch: Partial> + patch: Partial>, ): void { const next: HostRuntimeSnapshot = { ...this.snapshot, @@ -916,9 +885,7 @@ export class HostRuntimeController { return version === this.probeRequestVersion; } - private canProceedForProbe( - expectedProbeVersion: number | undefined - ): boolean { + private canProceedForProbe(expectedProbeVersion: number | undefined): boolean { if (expectedProbeVersion === undefined) { return true; } @@ -1006,12 +973,14 @@ export class HostRuntimeController { if (existingClient) { existingClient.setReconnectEnabled(true); } - const client = existingClient ?? this.deps.createClient({ - host: this.host, - connection, - clientId, - runtimeGeneration: nextGeneration, - }); + const client = + existingClient ?? + this.deps.createClient({ + host: this.host, + connection, + clientId, + runtimeGeneration: nextGeneration, + }); if (!this.isCurrentSwitchRequest(requestVersion)) { await client.close().catch(() => undefined); return; @@ -1038,10 +1007,7 @@ export class HostRuntimeController { } this.unsubscribeClientStatus = client.subscribeConnectionStatus((state) => { - if ( - !this.isCurrentSwitchRequest(requestVersion) || - this.activeClient !== client - ) { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { return; } this.applyConnectionEvent({ @@ -1077,10 +1043,7 @@ export class HostRuntimeController { await client.connect(); } } catch (error) { - if ( - !this.isCurrentSwitchRequest(requestVersion) || - this.activeClient !== client - ) { + if (!this.isCurrentSwitchRequest(requestVersion) || this.activeClient !== client) { return; } const message = toErrorMessage(error); @@ -1235,7 +1198,7 @@ export class HostRuntimeStore { type: "directTcp", endpoint: DEFAULT_LOCALHOST_ENDPOINT, }, - { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS } + { timeoutMs: DEFAULT_LOCALHOST_BOOTSTRAP_TIMEOUT_MS }, ); await this.upsertDirectConnection({ @@ -1320,9 +1283,7 @@ export class HostRuntimeStore { async renameHost(serverId: string, label: string): Promise { const next = this.hosts.map((h) => - h.serverId === serverId - ? { ...h, label, updatedAt: new Date().toISOString() } - : h + h.serverId === serverId ? { ...h, label, updatedAt: new Date().toISOString() } : h, ); this.setHostsAndSync(next); await this.persistHosts(); @@ -1397,7 +1358,7 @@ export class HostRuntimeStore { string, { connectionId: string; existingClient: DaemonClient } >; - } + }, ): void { this.hosts = hosts; this.syncHosts(hosts, options); @@ -1426,7 +1387,7 @@ export class HostRuntimeStore { string, { connectionId: string; existingClient: DaemonClient } >; - } + }, ): void { const nextIds = new Set(hosts.map((host) => host.serverId)); for (const [serverId, controller] of this.controllers) { @@ -1459,22 +1420,24 @@ export class HostRuntimeStore { this.controllers.set(host.serverId, controller); this.lastConnectionStatusByServer.set( host.serverId, - controller.getSnapshot().connectionStatus + controller.getSnapshot().connectionStatus, ); controller.subscribe(() => { this.maybeAutoBootstrapAgentDirectory(host.serverId); this.emit(host.serverId); }); - void controller.start({ - ...(initialConnection - ? { - initialConnection, - } - : {}), - }).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - controller.markStartupError(message); - }); + void controller + .start({ + ...(initialConnection + ? { + initialConnection, + } + : {}), + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + controller.markStartupError(message); + }); this.emit(host.serverId); } } @@ -1513,7 +1476,7 @@ export class HostRuntimeStore { serverId, subscribe: { subscriptionId: `app:${serverId}` }, page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, - }) + }), ) .then(() => undefined) .catch((error) => { @@ -1578,9 +1541,7 @@ export class HostRuntimeStore { return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve(); } return Promise.all( - Array.from(this.controllers.values(), (controller) => - controller.runProbeCycleNow() - ) + Array.from(this.controllers.values(), (controller) => controller.runProbeCycleNow()), ).then(() => undefined); } @@ -1653,9 +1614,7 @@ export class HostRuntimeStore { } refreshAllAgentDirectories(input?: { serverIds?: string[] }): void { - const targetServerIds = input?.serverIds - ? new Set(input.serverIds) - : null; + const targetServerIds = input?.serverIds ? new Set(input.serverIds) : null; for (const [serverId] of this.controllers) { if (targetServerIds && !targetServerIds.has(serverId)) { continue; @@ -1723,14 +1682,12 @@ export function getHostRuntimeStore(): HostRuntimeStore { return singletonHostRuntimeStore; } -export function useHostRuntimeSnapshot( - serverId: string -): HostRuntimeSnapshot | null { +export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null { const store = getHostRuntimeStore(); return useSyncExternalStore( (onStoreChange) => store.subscribe(serverId, onStoreChange), () => store.getSnapshot(serverId), - () => store.getSnapshot(serverId) + () => store.getSnapshot(serverId), ); } @@ -1739,7 +1696,7 @@ export function useHostRuntimeClient(serverId: string): DaemonClient | null { return useSyncExternalStore( (onStoreChange) => store.subscribe(serverId, onStoreChange), () => store.getSnapshot(serverId)?.client ?? null, - () => store.getSnapshot(serverId)?.client ?? null + () => store.getSnapshot(serverId)?.client ?? null, ); } @@ -1748,18 +1705,16 @@ export function useHostRuntimeIsConnected(serverId: string): boolean { return useSyncExternalStore( (onStoreChange) => store.subscribe(serverId, onStoreChange), () => isHostRuntimeConnected(store.getSnapshot(serverId)), - () => isHostRuntimeConnected(store.getSnapshot(serverId)) + () => isHostRuntimeConnected(store.getSnapshot(serverId)), ); } -export function useHostRuntimeConnectionStatus( - serverId: string -): HostRuntimeConnectionStatus { +export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus { const store = getHostRuntimeStore(); return useSyncExternalStore( (onStoreChange) => store.subscribe(serverId, onStoreChange), () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting", - () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting" + () => store.getSnapshot(serverId)?.connectionStatus ?? "connecting", ); } @@ -1768,18 +1723,18 @@ export function useHostRuntimeLastError(serverId: string): string | null { return useSyncExternalStore( (onStoreChange) => store.subscribe(serverId, onStoreChange), () => store.getSnapshot(serverId)?.lastError ?? null, - () => store.getSnapshot(serverId)?.lastError ?? null + () => store.getSnapshot(serverId)?.lastError ?? null, ); } export function useHostRuntimeAgentDirectoryStatus( - serverId: string + serverId: string, ): HostRuntimeAgentDirectoryStatus { const store = getHostRuntimeStore(); return useSyncExternalStore( (onStoreChange) => store.subscribe(serverId, onStoreChange), () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle", - () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle" + () => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle", ); } @@ -1788,7 +1743,7 @@ export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean { return useSyncExternalStore( (onStoreChange) => store.subscribe(serverId, onStoreChange), () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)), - () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)) + () => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)), ); } @@ -1797,7 +1752,7 @@ export function useHosts(): HostProfile[] { return useSyncExternalStore( (onStoreChange) => store.subscribeHostList(onStoreChange), () => store.getHosts(), - () => store.getHosts() + () => store.getHosts(), ); } @@ -1832,6 +1787,6 @@ export function useHostMutations(): HostMutations { removeHost: (serverId) => store.removeHost(serverId), removeConnection: (serverId, connectionId) => store.removeConnection(serverId, connectionId), }), - [store] + [store], ); } diff --git a/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts b/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts index a59135cbd..bcf20830c 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts +++ b/packages/app/src/screens/agent/agent-ready-screen.bottom-anchor.test.ts @@ -26,7 +26,7 @@ describe("agent-ready-screen bottom anchor intent", () => { deriveRouteBottomAnchorRequest({ intent: intentAfterHistoryApplies, effectiveAgentId: "agent-1", - }) + }), ).toEqual({ agentId: "agent-1", reason: "initial-entry", @@ -45,7 +45,7 @@ describe("agent-ready-screen bottom anchor intent", () => { deriveRouteBottomAnchorRequest({ intent, effectiveAgentId: "agent-2", - }) + }), ).toEqual({ agentId: "agent-2", reason: "resume", @@ -64,7 +64,7 @@ describe("agent-ready-screen bottom anchor intent", () => { deriveRouteBottomAnchorRequest({ intent, effectiveAgentId: null, - }) + }), ).toBeNull(); }); }); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 5951573db..5ffcfa9f7 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -1,59 +1,63 @@ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' -import { createNameId } from 'mnemonic-id' -import type { ImageAttachment } from '@/components/message-input' -import { View, Text, Pressable, ScrollView, Keyboard, Platform } from 'react-native' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { useIsFocused } from '@react-navigation/native' -import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import { GestureDetector } from 'react-native-gesture-handler' -import Animated from 'react-native-reanimated' -import { Folder, GitBranch, PanelRight } from 'lucide-react-native' -import { SidebarMenuToggle } from '@/components/headers/menu-header' -import { HeaderToggleButton } from '@/components/headers/header-toggle-button' -import { AgentInputArea } from '@/components/agent-input-area' -import { AgentStreamView } from '@/components/agent-stream-view' -import { FormSelectTrigger } from '@/components/agent-form/agent-form-dropdowns' -import { ExplorerSidebar } from '@/components/explorer-sidebar' -import { Combobox } from '@/components/ui/combobox' -import { FileDropZone } from '@/components/file-drop-zone' -import { useQuery } from '@tanstack/react-query' -import { useAgentFormState, type CreateAgentInitialValues } from '@/hooks/use-agent-form-state' -import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; +import { createNameId } from "mnemonic-id"; +import type { ImageAttachment } from "@/components/message-input"; +import { View, Text, Pressable, ScrollView, Keyboard, Platform } from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { useIsFocused } from "@react-navigation/native"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { GestureDetector } from "react-native-gesture-handler"; +import Animated from "react-native-reanimated"; +import { Folder, GitBranch, PanelRight } from "lucide-react-native"; +import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { AgentInputArea } from "@/components/agent-input-area"; +import { AgentStreamView } from "@/components/agent-stream-view"; +import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns"; +import { ExplorerSidebar } from "@/components/explorer-sidebar"; +import { Combobox } from "@/components/ui/combobox"; +import { FileDropZone } from "@/components/file-drop-zone"; +import { useQuery } from "@tanstack/react-query"; +import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, -} from '@/hooks/use-checkout-status-query' -import { useAllAgentsList } from '@/hooks/use-all-agents-list' -import { useHosts } from '@/runtime/host-runtime' -import { buildBranchComboOptions, normalizeBranchOptionName } from '@/utils/branch-suggestions' -import { shortenPath } from '@/utils/shorten-path' -import { collectAgentWorkingDirectorySuggestions } from '@/utils/agent-working-directory-suggestions' -import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-suggestions' -import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture' -import { useSessionStore } from '@/stores/session-store' -import { buildDraftStoreKey, generateDraftId } from '@/stores/draft-keys' -import { getHostRuntimeStore, useHostRuntimeClient, useHostRuntimeIsConnected } from '@/runtime/host-runtime' -import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' -import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store' -import { MAX_CONTENT_WIDTH } from '@/constants/layout' -import { WelcomeScreen } from '@/components/welcome-screen' -import type { Agent } from '@/contexts/session-context' -import { encodeImages } from '@/utils/encode-images' +} from "@/hooks/use-checkout-status-query"; +import { useAllAgentsList } from "@/hooks/use-all-agents-list"; +import { useHosts } from "@/runtime/host-runtime"; +import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; +import { shortenPath } from "@/utils/shorten-path"; +import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; +import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; +import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; +import { useSessionStore } from "@/stores/session-store"; +import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; +import { + getHostRuntimeStore, + useHostRuntimeClient, + useHostRuntimeIsConnected, +} from "@/runtime/host-runtime"; +import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; +import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { WelcomeScreen } from "@/components/welcome-screen"; +import type { Agent } from "@/contexts/session-context"; +import { encodeImages } from "@/utils/encode-images"; import type { AgentProvider, AgentCapabilityFlags, AgentSessionConfig, -} from '@server/server/agent/agent-sdk-types' -import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest' -import { prepareWorkspaceTab } from '@/utils/workspace-navigation' -import { useDesktopDragHandlers } from '@/utils/desktop-window' -import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' -import { normalizeAgentSnapshot } from '@/utils/agent-snapshots' -import { useAgentInputDraft } from '@/hooks/use-agent-input-draft' -import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow' +} from "@server/server/agent/agent-sdk-types"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { useDesktopDragHandlers } from "@/utils/desktop-window"; +import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; +import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; -const EMPTY_PENDING_PERMISSIONS = new Map() +const EMPTY_PENDING_PERMISSIONS = new Map(); const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: false, @@ -61,58 +65,58 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, -} +}; const PROVIDER_DEFINITION_MAP = new Map( - AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]) -) + AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]), +); function getParamValue(value: string | string[] | undefined) { - if (typeof value === 'string') { - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : undefined + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; } if (Array.isArray(value)) { for (const entry of value) { - const trimmed = entry.trim() + const trimmed = entry.trim(); if (trimmed.length > 0) { - return trimmed + return trimmed; } } } - return undefined + return undefined; } function getValidProvider(value: string | undefined) { if (!value) { - return undefined + return undefined; } - return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined + return PROVIDER_DEFINITION_MAP.has(value as AgentProvider) ? (value as AgentProvider) : undefined; } function getValidMode(provider: AgentProvider | undefined, value: string | undefined) { if (!provider || !value) { - return undefined + return undefined; } - const definition = PROVIDER_DEFINITION_MAP.get(provider) - const modes = definition?.modes ?? [] - return modes.some((mode) => mode.id === value) ? value : undefined + const definition = PROVIDER_DEFINITION_MAP.get(provider); + const modes = definition?.modes ?? []; + return modes.some((mode) => mode.id === value) ? value : undefined; } type DraftAgentParams = { - serverId?: string - provider?: string - modeId?: string - model?: string - thinkingOptionId?: string - workingDir?: string - worktreeMode?: string -} + serverId?: string; + provider?: string; + modeId?: string; + model?: string; + thinkingOptionId?: string; + workingDir?: string; + worktreeMode?: string; +}; type DraftAgentScreenProps = { - isVisible?: boolean - onCreateFlowActiveChange?: (active: boolean) => void - forcedServerId?: string -} + isVisible?: boolean; + onCreateFlowActiveChange?: (active: boolean) => void; + forcedServerId?: string; +}; export function DraftAgentScreen({ isVisible = true, @@ -127,7 +131,7 @@ export function DraftAgentScreen({ forcedServerId={forcedServerId} /> - ) + ); } function DraftAgentScreenContent({ @@ -135,68 +139,68 @@ function DraftAgentScreenContent({ onCreateFlowActiveChange, forcedServerId, }: DraftAgentScreenProps = {}) { - const isFocused = useIsFocused() - const { theme } = useUnistyles() - const router = useRouter() - const insets = useSafeAreaInsets() - const daemons = useHosts() - const runtime = getHostRuntimeStore() + const isFocused = useIsFocused(); + const { theme } = useUnistyles(); + const router = useRouter(); + const insets = useSafeAreaInsets(); + const daemons = useHosts(); + const runtime = getHostRuntimeStore(); const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), - () => runtime.getVersion() - ) - const params = useLocalSearchParams() + () => runtime.getVersion(), + ); + const params = useLocalSearchParams(); const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({ - mode: 'translate', - }) + mode: "translate", + }); - const forcedServerIdParam = forcedServerId?.trim() + const forcedServerIdParam = forcedServerId?.trim(); const resolvedServerId = forcedServerIdParam && forcedServerIdParam.length > 0 ? forcedServerIdParam - : getParamValue(params.serverId) - const resolvedProvider = getValidProvider(getParamValue(params.provider)) - const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)) - const resolvedModel = getParamValue(params.model) - const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId) - const resolvedWorkingDir = getParamValue(params.workingDir) - const resolvedWorktreeMode = getParamValue(params.worktreeMode) + : getParamValue(params.serverId); + const resolvedProvider = getValidProvider(getParamValue(params.provider)); + const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId)); + const resolvedModel = getParamValue(params.model); + const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId); + const resolvedWorkingDir = getParamValue(params.workingDir); + const resolvedWorktreeMode = getParamValue(params.worktreeMode); const initialWorktreeMode = - resolvedWorktreeMode === 'create' || resolvedWorktreeMode === 'attach' + resolvedWorktreeMode === "create" || resolvedWorktreeMode === "attach" ? resolvedWorktreeMode - : 'none' + : "none"; const onlineServerIds = useMemo(() => { - if (daemons.length === 0) return [] - const out: string[] = [] + if (daemons.length === 0) return []; + const out: string[] = []; for (const daemon of daemons) { - const status = runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? 'connecting' - if (status === 'online') out.push(daemon.serverId) + const status = runtime.getSnapshot(daemon.serverId)?.connectionStatus ?? "connecting"; + if (status === "online") out.push(daemon.serverId); } - return out - }, [daemons, runtime, runtimeVersion]) + return out; + }, [daemons, runtime, runtimeVersion]); const initialValues = useMemo((): CreateAgentInitialValues => { - const values: CreateAgentInitialValues = {} + const values: CreateAgentInitialValues = {}; if (resolvedWorkingDir) { - values.workingDir = resolvedWorkingDir + values.workingDir = resolvedWorkingDir; } if (resolvedProvider) { - values.provider = resolvedProvider + values.provider = resolvedProvider; } if (resolvedMode) { - values.modeId = resolvedMode + values.modeId = resolvedMode; } if (resolvedModel) { - values.model = resolvedModel + values.model = resolvedModel; } if (resolvedThinkingOptionId) { - values.thinkingOptionId = resolvedThinkingOptionId + values.thinkingOptionId = resolvedThinkingOptionId; } - return values - }, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]) + return values; + }, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]); const { selectedServerId, @@ -228,87 +232,87 @@ function DraftAgentScreenContent({ isVisible, isCreateFlow: true, onlineServerIds, - }) - const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const mobileView = usePanelStore((state) => state.mobileView) - const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen) - const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer) - const openFileExplorer = usePanelStore((state) => state.openFileExplorer) - const closeFileExplorer = usePanelStore((state) => state.closeFileExplorer) - const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout) + }); + const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const closeFileExplorer = usePanelStore((state) => state.closeFileExplorer); + const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); const activateExplorerTabForCheckout = usePanelStore( - (state) => state.activateExplorerTabForCheckout - ) - const dragHandlers = useDesktopDragHandlers() - const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen - const draftIdRef = useRef(generateDraftId()) - const draftAgentIdRef = useRef(generateDraftId()) + (state) => state.activateExplorerTabForCheckout, + ); + const dragHandlers = useDesktopDragHandlers(); + const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; + const draftIdRef = useRef(generateDraftId()); + const draftAgentIdRef = useRef(generateDraftId()); const draftInput = useAgentInputDraft( buildDraftStoreKey({ - serverId: selectedServerId ?? '', + serverId: selectedServerId ?? "", agentId: draftAgentIdRef.current, draftId: draftIdRef.current, - }) - ) + }), + ); - const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>( - initialWorktreeMode - ) - const [baseBranch, setBaseBranch] = useState('') - const [worktreeSlug, setWorktreeSlug] = useState('') - const [selectedWorktreePath, setSelectedWorktreePath] = useState('') - const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false) - const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false) - const [isBranchOpen, setIsBranchOpen] = useState(false) - const [branchSearchQuery, setBranchSearchQuery] = useState('') - const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState('') - const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState('') - const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState('') - const workingDirAnchorRef = useRef(null) - const worktreeAnchorRef = useRef(null) - const branchAnchorRef = useRef(null) - const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null) + const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">( + initialWorktreeMode, + ); + const [baseBranch, setBaseBranch] = useState(""); + const [worktreeSlug, setWorktreeSlug] = useState(""); + const [selectedWorktreePath, setSelectedWorktreePath] = useState(""); + const [isWorkingDirOpen, setIsWorkingDirOpen] = useState(false); + const [isWorktreePickerOpen, setIsWorktreePickerOpen] = useState(false); + const [isBranchOpen, setIsBranchOpen] = useState(false); + const [branchSearchQuery, setBranchSearchQuery] = useState(""); + const [debouncedBranchSearchQuery, setDebouncedBranchSearchQuery] = useState(""); + const [workingDirSearchQuery, setWorkingDirSearchQuery] = useState(""); + const [debouncedWorkingDirSearchQuery, setDebouncedWorkingDirSearchQuery] = useState(""); + const workingDirAnchorRef = useRef(null); + const worktreeAnchorRef = useRef(null); + const branchAnchorRef = useRef(null); + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); useEffect(() => { - const trimmed = branchSearchQuery.trim() - const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180) - return () => clearTimeout(timer) - }, [branchSearchQuery]) + const trimmed = branchSearchQuery.trim(); + const timer = setTimeout(() => setDebouncedBranchSearchQuery(trimmed), 180); + return () => clearTimeout(timer); + }, [branchSearchQuery]); useEffect(() => { - const trimmed = workingDirSearchQuery.trim() - const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180) - return () => clearTimeout(timer) - }, [workingDirSearchQuery]) + const trimmed = workingDirSearchQuery.trim(); + const timer = setTimeout(() => setDebouncedWorkingDirSearchQuery(trimmed), 180); + return () => clearTimeout(timer); + }, [workingDirSearchQuery]); const handleFilesDropped = useCallback((files: ImageAttachment[]) => { - addImagesRef.current?.(files) - }, []) + addImagesRef.current?.(files); + }, []); const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { - addImagesRef.current = addImages - }, []) + addImagesRef.current = addImages; + }, []); const sessionAgents = useSessionStore((state) => - selectedServerId ? state.sessions[selectedServerId]?.agents : undefined - ) - const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId }) + selectedServerId ? state.sessions[selectedServerId]?.agents : undefined, + ); + const { agents: allAgents } = useAllAgentsList({ serverId: selectedServerId }); const worktreePathLastCreatedAt = useMemo(() => { - const map = new Map() + const map = new Map(); if (!sessionAgents) { - return map + return map; } sessionAgents.forEach((agent) => { if (!agent.cwd) { - return + return; } - const ts = agent.createdAt.getTime() - const prev = map.get(agent.cwd) + const ts = agent.createdAt.getTime(); + const prev = map.get(agent.cwd); if (!prev || ts > prev) { - map.set(agent.cwd, ts) + map.set(agent.cwd, ts); } - }) - return map - }, [sessionAgents]) + }); + return map; + }, [sessionAgents]); const agentWorkingDirSuggestions = useMemo(() => { const liveSources = sessionAgents ? Array.from(sessionAgents.values()).map((agent) => ({ @@ -316,147 +320,144 @@ function DraftAgentScreenContent({ createdAt: agent.createdAt, lastActivityAt: agent.lastActivityAt, })) - : [] + : []; const fetchedSources = allAgents.map((agent) => ({ cwd: agent.cwd, lastActivityAt: agent.lastActivityAt, - })) + })); - return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]) - }, [allAgents, sessionAgents]) + return collectAgentWorkingDirectorySuggestions([...liveSources, ...fetchedSources]); + }, [allAgents, sessionAgents]); - const runtimeClient = useHostRuntimeClient(selectedServerId ?? '') - const isHostOnline = useHostRuntimeIsConnected(selectedServerId ?? '') - const sessionClient = runtimeClient - const trimmedWorkingDir = workingDir.trim() - const shouldInspectRepo = trimmedWorkingDir.length > 0 - const canQuerySelectedHost = Boolean(selectedServerId) && Boolean(sessionClient) && isHostOnline + const runtimeClient = useHostRuntimeClient(selectedServerId ?? ""); + const isHostOnline = useHostRuntimeIsConnected(selectedServerId ?? ""); + const sessionClient = runtimeClient; + const trimmedWorkingDir = workingDir.trim(); + const shouldInspectRepo = trimmedWorkingDir.length > 0; + const canQuerySelectedHost = Boolean(selectedServerId) && Boolean(sessionClient) && isHostOnline; const checkoutStatusQuery = useQuery({ - queryKey: checkoutStatusQueryKey(selectedServerId ?? '', trimmedWorkingDir), + queryKey: checkoutStatusQueryKey(selectedServerId ?? "", trimmedWorkingDir), queryFn: async () => { - const client = sessionClient + const client = sessionClient; if (!client) { - throw new Error('Daemon client unavailable') + throw new Error("Daemon client unavailable"); } - return await client.getCheckoutStatus(trimmedWorkingDir) + return await client.getCheckoutStatus(trimmedWorkingDir); }, enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost, retry: false, staleTime: CHECKOUT_STATUS_STALE_TIME, - refetchOnMount: 'always', - }) + refetchOnMount: "always", + }); - const checkout = checkoutStatusQuery.data ?? null + const checkout = checkoutStatusQuery.data ?? null; const checkoutQueryError = - checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null - const checkoutPayloadError = checkout?.error ? checkout.error.message : null - const isGitDirectory = checkoutStatusQuery.isSuccess && checkout?.isGit === true + checkoutStatusQuery.error instanceof Error ? checkoutStatusQuery.error.message : null; + const checkoutPayloadError = checkout?.error ? checkout.error.message : null; + const isGitDirectory = checkoutStatusQuery.isSuccess && checkout?.isGit === true; const isNonGitDirectory = Boolean(trimmedWorkingDir) && checkoutStatusQuery.isSuccess && checkout?.isGit === false && - checkout?.error == null + checkout?.error == null; const isDirectoryNotExists = checkoutStatusQuery.isError && - /does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? '') + /does not exist|no such file or directory|ENOENT/i.test(checkoutQueryError ?? ""); - const repoInfoStatus: 'idle' | 'loading' | 'ready' | 'error' = !shouldInspectRepo - ? 'idle' + const repoInfoStatus: "idle" | "loading" | "ready" | "error" = !shouldInspectRepo + ? "idle" : !canQuerySelectedHost - ? 'idle' + ? "idle" : checkoutStatusQuery.isPending || checkoutStatusQuery.isFetching - ? 'loading' + ? "loading" : checkoutStatusQuery.isError || Boolean(checkoutPayloadError) - ? 'error' + ? "error" : checkout?.isGit - ? 'ready' - : 'idle' + ? "ready" + : "idle"; const repoInfoError = - (checkoutStatusQuery.isError ? checkoutQueryError : null) ?? checkoutPayloadError - const isCreateWorktree = worktreeMode === 'create' - const isAttachWorktree = worktreeMode === 'attach' + (checkoutStatusQuery.isError ? checkoutQueryError : null) ?? checkoutPayloadError; + const isCreateWorktree = worktreeMode === "create"; + const isAttachWorktree = worktreeMode === "attach"; - const worktreeListRoot = checkout?.isGit ? checkout.repoRoot : '' + const worktreeListRoot = checkout?.isGit ? checkout.repoRoot : ""; const worktreeListQuery = useQuery({ - queryKey: ['paseoWorktreeList', selectedServerId, worktreeListRoot], + queryKey: ["paseoWorktreeList", selectedServerId, worktreeListRoot], queryFn: async () => { - const client = sessionClient + const client = sessionClient; if (!client) { - throw new Error('Daemon client unavailable') + throw new Error("Daemon client unavailable"); } const payload = await client.getPaseoWorktreeList({ repoRoot: worktreeListRoot || undefined, cwd: worktreeListRoot ? undefined : trimmedWorkingDir || undefined, - }) + }); if (payload.error) { - throw new Error(payload.error.message) + throw new Error(payload.error.message); } - return payload.worktrees ?? [] + return payload.worktrees ?? []; }, enabled: - isGitDirectory && - Boolean(worktreeListRoot) && - canQuerySelectedHost && - !isNonGitDirectory, + isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory, retry: false, staleTime: 0, - refetchOnMount: 'always', - }) + refetchOnMount: "always", + }); const worktreeOptions = useMemo(() => { const options = (worktreeListQuery.data ?? []).map((worktree) => ({ path: worktree.worktreePath, - label: worktree.branchName ?? worktree.head ?? 'Unknown branch', - })) + label: worktree.branchName ?? worktree.head ?? "Unknown branch", + })); return options.sort((a, b) => { - const aTs = worktreePathLastCreatedAt.get(a.path) ?? 0 - const bTs = worktreePathLastCreatedAt.get(b.path) ?? 0 + const aTs = worktreePathLastCreatedAt.get(a.path) ?? 0; + const bTs = worktreePathLastCreatedAt.get(b.path) ?? 0; if (aTs !== bTs) { - return bTs - aTs + return bTs - aTs; } - return a.label.localeCompare(b.label) - }) - }, [worktreeListQuery.data, worktreePathLastCreatedAt]) + return a.label.localeCompare(b.label); + }); + }, [worktreeListQuery.data, worktreePathLastCreatedAt]); const worktreeOptionsError = - worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null - const worktreeOptionsStatus: 'idle' | 'loading' | 'ready' | 'error' = + worktreeListQuery.error instanceof Error ? worktreeListQuery.error.message : null; + const worktreeOptionsStatus: "idle" | "loading" | "ready" | "error" = worktreeListQuery.isPending || worktreeListQuery.isFetching - ? 'loading' + ? "loading" : worktreeListQuery.isError - ? 'error' - : 'ready' + ? "error" + : "ready"; const attachWorktreeError = isAttachWorktree && - worktreeOptionsStatus === 'ready' && + worktreeOptionsStatus === "ready" && worktreeOptions.length > 0 && !selectedWorktreePath - ? 'Select a worktree to attach' - : null + ? "Select a worktree to attach" + : null; const branchSuggestionsQuery = useQuery({ queryKey: [ - 'branchSuggestions', + "branchSuggestions", selectedServerId, trimmedWorkingDir, debouncedBranchSearchQuery, ], queryFn: async () => { - const client = sessionClient + const client = sessionClient; if (!client) { - throw new Error('Daemon client unavailable') + throw new Error("Daemon client unavailable"); } const payload = await client.getBranchSuggestions({ - cwd: trimmedWorkingDir || '.', + cwd: trimmedWorkingDir || ".", query: debouncedBranchSearchQuery || undefined, limit: 50, - }) + }); if (payload.error) { - throw new Error(payload.error) + throw new Error(payload.error); } - return payload.branches ?? [] + return payload.branches ?? []; }, enabled: isCreateWorktree && @@ -466,91 +467,89 @@ function DraftAgentScreenContent({ canQuerySelectedHost, retry: false, staleTime: 15_000, - }) + }); const directorySuggestionsQuery = useQuery({ - queryKey: ['directorySuggestions', selectedServerId, debouncedWorkingDirSearchQuery], + queryKey: ["directorySuggestions", selectedServerId, debouncedWorkingDirSearchQuery], queryFn: async () => { - const client = sessionClient + const client = sessionClient; if (!client) { - throw new Error('Daemon client unavailable') + throw new Error("Daemon client unavailable"); } const payload = await client.getDirectorySuggestions({ query: debouncedWorkingDirSearchQuery, limit: 50, includeDirectories: true, includeFiles: false, - }) + }); if (payload.error) { - throw new Error(payload.error) + throw new Error(payload.error); } if (payload.entries.length > 0) { return payload.entries - .filter((entry) => entry.kind === 'directory') - .map((entry) => entry.path) + .filter((entry) => entry.kind === "directory") + .map((entry) => entry.path); } - return payload.directories ?? [] + return payload.directories ?? []; }, - enabled: - Boolean(debouncedWorkingDirSearchQuery) && - canQuerySelectedHost, + enabled: Boolean(debouncedWorkingDirSearchQuery) && canQuerySelectedHost, retry: false, staleTime: 15_000, - }) + }); const validateWorktreeName = useCallback((name: string): { valid: boolean; error?: string } => { if (!name) { - return { valid: true } + return { valid: true }; } if (name.length > 100) { return { valid: false, - error: 'Worktree name too long (max 100 characters)', - } + error: "Worktree name too long (max 100 characters)", + }; } if (!/^[a-z0-9-/]+$/.test(name)) { return { valid: false, - error: 'Must contain only lowercase letters, numbers, hyphens, and forward slashes', - } + error: "Must contain only lowercase letters, numbers, hyphens, and forward slashes", + }; } - if (name.startsWith('-') || name.endsWith('-')) { - return { valid: false, error: 'Cannot start or end with a hyphen' } + if (name.startsWith("-") || name.endsWith("-")) { + return { valid: false, error: "Cannot start or end with a hyphen" }; } - if (name.includes('--')) { - return { valid: false, error: 'Cannot have consecutive hyphens' } + if (name.includes("--")) { + return { valid: false, error: "Cannot have consecutive hyphens" }; } - return { valid: true } - }, []) + return { valid: true }; + }, []); const gitBlockingError = useMemo(() => { if (!isCreateWorktree || isNonGitDirectory) { - return null + return null; } if (!worktreeSlug) { - return null + return null; } - const validation = validateWorktreeName(worktreeSlug) + const validation = validateWorktreeName(worktreeSlug); if (!validation.valid) { return `Invalid worktree name: ${ - validation.error ?? 'Must use lowercase letters, numbers, or hyphens' - }` + validation.error ?? "Must use lowercase letters, numbers, or hyphens" + }`; } - return null - }, [isCreateWorktree, isNonGitDirectory, worktreeSlug, validateWorktreeName]) + return null; + }, [isCreateWorktree, isNonGitDirectory, worktreeSlug, validateWorktreeName]); // Validate branch exists (checks local first, then remote) const branchValidationQuery = useQuery({ - queryKey: ['validateBranch', selectedServerId, trimmedWorkingDir, baseBranch], + queryKey: ["validateBranch", selectedServerId, trimmedWorkingDir, baseBranch], queryFn: async () => { - const client = sessionClient + const client = sessionClient; if (!client) { - throw new Error('Daemon client unavailable') + throw new Error("Daemon client unavailable"); } return client.validateBranch({ - cwd: trimmedWorkingDir || '.', + cwd: trimmedWorkingDir || ".", branchName: baseBranch, - }) + }); }, enabled: isCreateWorktree && @@ -562,105 +561,105 @@ function DraftAgentScreenContent({ isHostOnline, retry: false, staleTime: 30_000, - }) + }); const baseBranchError = useMemo(() => { if (!isCreateWorktree || isNonGitDirectory) { - return null + return null; } if (!baseBranch) { - return 'Base branch is required' + return "Base branch is required"; } // While validating, don't show error if (branchValidationQuery.isPending || branchValidationQuery.isFetching) { - return null + return null; } // If validation query errored, show generic error if (branchValidationQuery.isError) { - return 'Failed to validate branch' + return "Failed to validate branch"; } // If validation completed and branch doesn't exist - const validationResult = branchValidationQuery.data + const validationResult = branchValidationQuery.data; if (validationResult && !validationResult.exists) { - return `Branch "${baseBranch}" not found in repository` + return `Branch "${baseBranch}" not found in repository`; } - return null - }, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]) + return null; + }, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]); const handleBaseBranchChange = useCallback((value: string) => { - setBaseBranch(value) - }, []) + setBaseBranch(value); + }, []); const handleSelectWorktreePath = useCallback((path: string) => { - setSelectedWorktreePath(path) - }, []) + setSelectedWorktreePath(path); + }, []); useEffect(() => { if (!isCreateWorktree || isNonGitDirectory) { - return + return; } if (baseBranch) { - return + return; } - const current = checkout?.isGit ? checkout.currentBranch?.trim() : null - if (!current || current === 'HEAD') { - return + const current = checkout?.isGit ? checkout.currentBranch?.trim() : null; + if (!current || current === "HEAD") { + return; } if (current) { - setBaseBranch(current) + setBaseBranch(current); } - }, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]) + }, [isCreateWorktree, isNonGitDirectory, baseBranch, checkout]); useEffect(() => { - if (isNonGitDirectory && worktreeMode !== 'none') { - setWorktreeMode('none') - setSelectedWorktreePath('') + if (isNonGitDirectory && worktreeMode !== "none") { + setWorktreeMode("none"); + setSelectedWorktreePath(""); } - }, [isNonGitDirectory, worktreeMode]) + }, [isNonGitDirectory, worktreeMode]); const selectedWorktreeLabel = - worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? '' + worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? ""; const explorerCwd = useMemo( () => (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim(), - [isAttachWorktree, selectedWorktreePath, workingDir] - ) + [isAttachWorktree, selectedWorktreePath, workingDir], + ); const draftExplorerCheckout = useMemo(() => { if (!selectedServerId || !explorerCwd) { - return null + return null; } return { serverId: selectedServerId, cwd: explorerCwd, isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true, - } - }, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]) - const canOpenExplorer = draftExplorerCheckout !== null + }; + }, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]); + const canOpenExplorer = draftExplorerCheckout !== null; const openExplorerForDraftCheckout = useCallback(() => { if (!draftExplorerCheckout) { - return + return; } - activateExplorerTabForCheckout(draftExplorerCheckout) - openFileExplorer() - }, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]) + activateExplorerTabForCheckout(draftExplorerCheckout); + openFileExplorer(); + }, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]); const handleToggleExplorer = useCallback(() => { if (!canOpenExplorer) { - return + return; } if (isExplorerOpen) { - toggleFileExplorer() - return + toggleFileExplorer(); + return; } - openExplorerForDraftCheckout() - }, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]) + openExplorerForDraftCheckout(); + }, [canOpenExplorer, isExplorerOpen, openExplorerForDraftCheckout, toggleFileExplorer]); const explorerOpenGesture = useExplorerOpenGesture({ - enabled: isMobile && mobileView === 'agent' && canOpenExplorer, + enabled: isMobile && mobileView === "agent" && canOpenExplorer, onOpen: openExplorerForDraftCheckout, - }) - const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0 + }); + const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0; const workingDirSearchError = directorySuggestionsQuery.error instanceof Error ? directorySuggestionsQuery.error.message - : null + : null; const workingDirSuggestionPaths = useMemo( () => buildWorkingDirectorySuggestions({ @@ -673,41 +672,41 @@ function DraftAgentScreenContent({ directorySuggestionsQuery.data, hasWorkingDirectorySearch, workingDirSearchQuery, - ] - ) + ], + ); const workingDirComboOptions = useMemo( () => workingDirSuggestionPaths.map((path) => ({ id: path, label: shortenPath(path), - kind: 'directory' as const, + kind: "directory" as const, })), - [workingDirSuggestionPaths] - ) + [workingDirSuggestionPaths], + ); const workingDirEmptyText = useMemo(() => { if (hasWorkingDirectorySearch) { if (workingDirSearchError) { - return 'Failed to search directories on this host.' + return "Failed to search directories on this host."; } - return 'No directories match your search.' + return "No directories match your search."; } return agentWorkingDirSuggestions.length > 0 - ? 'No agent directories match your search.' - : 'No agent directories match your search.' - }, [agentWorkingDirSuggestions.length, hasWorkingDirectorySearch, workingDirSearchError]) - const displayWorkingDir = shortenPath(workingDir) + ? "No agent directories match your search." + : "No agent directories match your search."; + }, [agentWorkingDirSuggestions.length, hasWorkingDirectorySearch, workingDirSearchError]); + const displayWorkingDir = shortenPath(workingDir); const worktreeTriggerValue = - worktreeMode === 'create' ? 'Create new worktree' : selectedWorktreeLabel || 'Select worktree' + worktreeMode === "create" ? "Create new worktree" : selectedWorktreeLabel || "Select worktree"; const worktreeComboOptions = useMemo( () => [ { - id: '__none__', - label: 'None', + id: "__none__", + label: "None", }, { - id: '__create_new__', - label: 'Create new worktree', + id: "__create_new__", + label: "Create new worktree", }, ...worktreeOptions.map((option) => ({ id: option.path, @@ -715,8 +714,8 @@ function DraftAgentScreenContent({ description: shortenPath(option.path), })), ], - [worktreeOptions] - ) + [worktreeOptions], + ); const branchComboOptions = useMemo(() => { const options = buildBranchComboOptions({ @@ -725,43 +724,43 @@ function DraftAgentScreenContent({ baseRef: checkout?.isGit ? checkout.baseRef : null, typedBaseBranch: baseBranch, worktreeBranchLabels: worktreeOptions.map((option) => option.label), - }) + }); - const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? '' + const normalizedQuery = normalizeBranchOptionName(branchSearchQuery)?.toLowerCase() ?? ""; if (!normalizedQuery) { - return options + return options; } return options.sort((a, b) => { - const aLower = a.label.toLowerCase() - const bLower = b.label.toLowerCase() - const aPrefix = aLower.startsWith(normalizedQuery) - const bPrefix = bLower.startsWith(normalizedQuery) + const aLower = a.label.toLowerCase(); + const bLower = b.label.toLowerCase(); + const aPrefix = aLower.startsWith(normalizedQuery); + const bPrefix = bLower.startsWith(normalizedQuery); if (aPrefix !== bPrefix) { - return aPrefix ? -1 : 1 + return aPrefix ? -1 : 1; } - return aLower.localeCompare(bLower) - }) - }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]) + return aLower.localeCompare(bLower); + }); + }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]); - const createAgentClient = sessionClient + const createAgentClient = sessionClient; const draftCommandConfig = useMemo(() => { const cwd = ( isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir - ).trim() + ).trim(); if (!cwd) { - return undefined + return undefined; } return { provider: selectedProvider, cwd, - ...(modeOptions.length > 0 && selectedMode !== '' ? { modeId: selectedMode } : {}), + ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), ...(selectedModel.trim() ? { model: selectedModel.trim() } : {}), ...(selectedThinkingOptionId.trim() ? { thinkingOptionId: selectedThinkingOptionId.trim() } : {}), - } + }; }, [ isAttachWorktree, modeOptions.length, @@ -771,7 +770,7 @@ function DraftAgentScreenContent({ selectedThinkingOptionId, selectedWorktreePath, workingDir, - ]) + ]); const { formErrorMessage, @@ -783,64 +782,65 @@ function DraftAgentScreenContent({ draftId: draftIdRef.current, getPendingServerId: () => selectedServerId, validateBeforeSubmit: ({ text }) => { - const trimmedPath = workingDir.trim() + const trimmedPath = workingDir.trim(); if (!trimmedPath) { - return 'Working directory is required' + return "Working directory is required"; } if (isDirectoryNotExists) { - return 'Working directory does not exist on the selected host' + return "Working directory does not exist on the selected host"; } if (!text.trim()) { - return 'Initial prompt is required' + return "Initial prompt is required"; } if (!selectedServerId) { - return 'No host selected' + return "No host selected"; } if (providerDefinitions.length === 0) { - return 'No available providers on the selected host' + return "No available providers on the selected host"; } if (gitBlockingError) { - return gitBlockingError + return gitBlockingError; } if (isAttachWorktree && !selectedWorktreePath) { - return 'Select a worktree to attach' + return "Select a worktree to attach"; } if (baseBranchError) { - return baseBranchError + return baseBranchError; } if (!createAgentClient) { - return 'Host is not connected' + return "Host is not connected"; } - return null + return null; }, onBeforeSubmit: () => { - void persistFormPreferences() - if (Platform.OS === 'web') { - ;(document.activeElement as HTMLElement | null)?.blur?.() + void persistFormPreferences(); + if (Platform.OS === "web") { + (document.activeElement as HTMLElement | null)?.blur?.(); } - Keyboard.dismiss() + Keyboard.dismiss(); }, onCreateStart: () => { - onCreateFlowActiveChange?.(true) + onCreateFlowActiveChange?.(true); }, onCreateError: () => { - onCreateFlowActiveChange?.(false) + onCreateFlowActiveChange?.(false); }, buildDraftAgent: (attempt) => { - const serverId = selectedServerId ?? '' - const now = attempt.timestamp + const serverId = selectedServerId ?? ""; + const now = attempt.timestamp; const cwd = - (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || '.' - const provider = selectedProvider - const model = selectedModel.trim() || null - const thinkingOptionId = selectedThinkingOptionId.trim() || null - const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : null + (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || + "."; + const provider = selectedProvider; + const model = selectedModel.trim() || null; + const thinkingOptionId = selectedThinkingOptionId.trim() || null; + const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; return { serverId, id: draftAgentIdRef.current, provider, - status: 'running', + status: "running", createdAt: now, updatedAt: now, lastUserMessageAt: now, @@ -856,34 +856,34 @@ function DraftAgentScreenContent({ model, modeId, }, - title: 'New agent', + title: "New agent", cwd, model, thinkingOptionId, labels: {}, - } + }; }, createRequest: async ({ attempt, text, images }) => { - const trimmedPath = workingDir.trim() + const trimmedPath = workingDir.trim(); const resolvedWorkingDir = - isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath + isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath; - const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined - const trimmedModel = selectedModel.trim() - const trimmedThinkingOptionId = selectedThinkingOptionId.trim() + const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + const trimmedModel = selectedModel.trim(); + const trimmedThinkingOptionId = selectedThinkingOptionId.trim(); const config: AgentSessionConfig = { provider: selectedProvider, cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), ...(trimmedModel ? { model: trimmedModel } : {}), ...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}), - } + }; - const effectiveBaseBranch = baseBranch.trim() + const effectiveBaseBranch = baseBranch.trim(); const effectiveWorktreeSlug = - isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug + isCreateWorktree && !worktreeSlug ? createNameId() : worktreeSlug; if (isCreateWorktree && !worktreeSlug && effectiveWorktreeSlug) { - setWorktreeSlug(effectiveWorktreeSlug) + setWorktreeSlug(effectiveWorktreeSlug); } const gitOptions = @@ -895,35 +895,35 @@ function DraftAgentScreenContent({ worktreeSlug: effectiveWorktreeSlug, baseBranch: effectiveBaseBranch, } - : undefined + : undefined; - const client = createAgentClient + const client = createAgentClient; if (!client) { - throw new Error('Host is not connected') + throw new Error("Host is not connected"); } - const imagesData = await encodeImages(images) + const imagesData = await encodeImages(images); const result = await client.createAgent({ config, initialPrompt: text, clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), git: gitOptions, - }) + }); if (!result.id || !selectedServerId) { - throw new Error('Failed to create agent') + throw new Error("Failed to create agent"); } useSessionStore.getState().setAgents(selectedServerId, (prev) => { - const next = new Map(prev) - next.set(result.id, normalizeAgentSnapshot(result, selectedServerId)) - return next - }) + const next = new Map(prev); + next.set(result.id, normalizeAgentSnapshot(result, selectedServerId)); + return next; + }); - const createdWorkingDir = typeof result.cwd === 'string' ? result.cwd.trim() : '' - const configuredWorkingDir = config.cwd.trim() - const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir + const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : ""; + const configuredWorkingDir = config.cwd.trim(); + const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir; return { agentId: result.id, @@ -931,53 +931,53 @@ function DraftAgentScreenContent({ id: result.id, cwd: workspaceId, }, - } + }; }, onCreateSuccess: ({ result }) => { const route = prepareWorkspaceTab({ serverId: selectedServerId as string, workspaceId: result.cwd, - target: { kind: 'agent', agentId: result.id }, - }) - router.replace(route as any) + target: { kind: "agent", agentId: result.id }, + }); + router.replace(route as any); }, - }) + }); useEffect(() => { if (!isFocused) { - return + return; } - setActiveExplorerCheckout(draftExplorerCheckout) - }, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]) + setActiveExplorerCheckout(draftExplorerCheckout); + }, [draftExplorerCheckout, isFocused, setActiveExplorerCheckout]); useEffect(() => { if (!isFocused || !draftExplorerCheckout) { - return + return; } - activateExplorerTabForCheckout(draftExplorerCheckout) - }, [activateExplorerTabForCheckout, draftExplorerCheckout, isFocused]) + activateExplorerTabForCheckout(draftExplorerCheckout); + }, [activateExplorerTabForCheckout, draftExplorerCheckout, isFocused]); useEffect(() => { if (!isFocused || canOpenExplorer || !isExplorerOpen) { - return + return; } - closeFileExplorer() - }, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused]) + closeFileExplorer(); + }, [canOpenExplorer, closeFileExplorer, isExplorerOpen, isFocused]); useEffect(() => { return () => { - setActiveExplorerCheckout(null) - } - }, [setActiveExplorerCheckout]) + setActiveExplorerCheckout(null); + }; + }, [setActiveExplorerCheckout]); if (daemons.length === 0) { return ( { - setSelectedServerIdFromUser(profile.serverId) + setSelectedServerIdFromUser(profile.serverId); }} /> - ) + ); } - const explorerServerId = draftExplorerCheckout?.serverId ?? null - const explorerIsGit = draftExplorerCheckout?.isGit ?? false + const explorerServerId = draftExplorerCheckout?.serverId ?? null; + const explorerIsGit = draftExplorerCheckout?.isGit ?? false; const mainContent = ( @@ -994,12 +994,12 @@ function DraftAgentScreenContent({ setIsWorkingDirOpen(true)} - icon={} + icon={ + + } showLabel={false} valueEllipsizeMode="middle" testID="working-directory-select" @@ -1059,7 +1061,7 @@ function DraftAgentScreenContent({ containerStyle={ isMobile ? styles.fullSelector - : worktreeMode === 'create' + : worktreeMode === "create" ? styles.halfSelector : styles.topSelectorPrimary } @@ -1067,12 +1069,17 @@ function DraftAgentScreenContent({ value={worktreeTriggerValue} placeholder="Select worktree" onPress={() => setIsWorktreePickerOpen(true)} - icon={} + icon={ + + } showLabel={false} valueEllipsizeMode="middle" testID="worktree-select-trigger" /> - {worktreeMode === 'create' ? ( + {worktreeMode === "create" ? ( setIsBranchOpen(true)} - disabled={repoInfoStatus === 'loading'} - icon={} + disabled={repoInfoStatus === "loading"} + icon={ + + } showLabel={false} testID="worktree-base-branch-trigger" /> @@ -1107,28 +1119,28 @@ function DraftAgentScreenContent({ { - if (id === '__create_new__') { - setWorktreeMode('create') + if (id === "__create_new__") { + setWorktreeMode("create"); if (!worktreeSlug) { - setWorktreeSlug(createNameId()) + setWorktreeSlug(createNameId()); } - setSelectedWorktreePath('') - return + setSelectedWorktreePath(""); + return; } - if (id === '__none__') { - setWorktreeMode('none') - setSelectedWorktreePath('') - return + if (id === "__none__") { + setWorktreeMode("none"); + setSelectedWorktreePath(""); + return; } - handleSelectWorktreePath(id) - setWorktreeMode('attach') + handleSelectWorktreePath(id); + setWorktreeMode("attach"); }} title="Select worktree" searchPlaceholder="Search worktrees..." @@ -1167,9 +1179,9 @@ function DraftAgentScreenContent({ title="Select base branch" open={isBranchOpen} onOpenChange={(nextOpen) => { - setIsBranchOpen(nextOpen) + setIsBranchOpen(nextOpen); if (!nextOpen) { - setBranchSearchQuery('') + setBranchSearchQuery(""); } }} anchorRef={branchAnchorRef} @@ -1186,7 +1198,7 @@ function DraftAgentScreenContent({ - ) + ); return ( @@ -1254,7 +1266,7 @@ function DraftAgentScreenContent({ ) : null} - ) + ); } const styles = StyleSheet.create((theme) => ({ @@ -1264,7 +1276,7 @@ const styles = StyleSheet.create((theme) => ({ }, outerContainer: { flex: 1, - flexDirection: 'row', + flexDirection: "row", }, agentPanel: { flex: 1, @@ -1274,17 +1286,17 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[2], }, menuToggleRow: { - width: '100%', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', + width: "100%", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", }, menuButton: { marginLeft: theme.spacing[2], }, contentContainer: { flex: 1, - overflow: 'hidden', + overflow: "hidden", }, scrollView: { flex: 1, @@ -1297,7 +1309,7 @@ const styles = StyleSheet.create((theme) => ({ }, configScrollContent: { flexGrow: 1, - justifyContent: 'flex-end', + justifyContent: "flex-end", }, configSection: { paddingHorizontal: { @@ -1308,19 +1320,19 @@ const styles = StyleSheet.create((theme) => ({ paddingBottom: theme.spacing[4], gap: theme.spacing[2], maxWidth: MAX_CONTENT_WIDTH, - alignSelf: 'center', - width: '100%', + alignSelf: "center", + width: "100%", }, topSelectorRow: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], }, stackedSelectorGroup: { gap: theme.spacing[2], }, fullSelector: { - width: '100%', + width: "100%", }, formSeparator: { height: theme.borderWidth[1], @@ -1360,7 +1372,7 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.palette.yellow[400], }, warningText: { - color: '#000000', + color: "#000000", fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, }, @@ -1368,4 +1380,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.palette.red[500], fontSize: theme.fontSize.base, }, -})) +})); diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index b5ceca840..2be9c43bd 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -38,10 +38,7 @@ export function OpenProjectScreen({ serverId }: { serverId: string }) { What shall we build today? [ - styles.openButton, - hovered && styles.openButtonHovered, - ]} + style={({ hovered }) => [styles.openButton, hovered && styles.openButtonHovered]} onPress={() => { void openProjectPicker(); }} diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index 8610b0b1b..e775ad6cb 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -38,9 +38,7 @@ function SessionsScreenContent({ serverId }: { serverId: string }) { }, [isRevalidating, isManualRefresh]); const sortedAgents = useMemo(() => { - return [...agents].sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime() - ); + return [...agents].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); }, [agents]); return ( diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 7404aab26..87d6fa0b5 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1,13 +1,6 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { MutableRefObject } from "react"; -import { - View, - Text, - ScrollView, - Alert, - Platform, - Pressable, -} from "react-native"; +import { View, Text, ScrollView, Alert, Platform, Pressable } from "react-native"; import { router, useLocalSearchParams } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -82,13 +75,22 @@ function formatActiveConnectionBadge(input: { return null; } if (activeConnection.type === "relay") { - return { icon: , text: "Relay" }; + return { + icon: , + text: "Relay", + }; } if (activeConnection.type === "directSocket") { - return { icon: , text: "Local" }; + return { + icon: , + text: "Local", + }; } if (activeConnection.type === "directPipe") { - return { icon: , text: "Local" }; + return { + icon: , + text: "Local", + }; } return { icon: , @@ -416,7 +418,7 @@ function DesktopAppUpdateRow() { } void checkForUpdates({ silent: true }); return undefined; - }, [checkForUpdates, isDesktop]) + }, [checkForUpdates, isDesktop]), ); const handleCheckForUpdates = useCallback(() => { @@ -498,15 +500,21 @@ export default function SettingsScreen() { const params = useLocalSearchParams<{ editHost?: string; serverId?: string }>(); const routeServerId = typeof params.serverId === "string" ? params.serverId.trim() : ""; const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings(); - const { - daemons, renameHost, removeHost, removeConnection, - } = { daemons: useHosts(), ...useHostMutations() }; + const { daemons, renameHost, removeHost, removeConnection } = { + daemons: useHosts(), + ...useHostMutations(), + }; const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false); const [isDirectHostVisible, setIsDirectHostVisible] = useState(false); const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false); - const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState(null); + const [addConnectionTargetServerId, setAddConnectionTargetServerId] = useState( + null, + ); const [pendingEditReopenServerId, setPendingEditReopenServerId] = useState(null); - const [pendingNameHost, setPendingNameHost] = useState<{ serverId: string; hostname: string | null } | null>(null); + const [pendingNameHost, setPendingNameHost] = useState<{ + serverId: string; + hostname: string | null; + } | null>(null); const [pendingRemoveHost, setPendingRemoveHost] = useState(null); const [isRemovingHost, setIsRemovingHost] = useState(false); const [editingDaemon, setEditingDaemon] = useState(null); @@ -522,16 +530,20 @@ export default function SettingsScreen() { const appVersionText = formatVersionWithPrefix(appVersion); const editingServerId = editingDaemon?.serverId ?? null; const editingDaemonLive = editingServerId - ? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null + ? (daemons.find((daemon) => daemon.serverId === editingServerId) ?? null) : null; const pendingNameHostname = useSessionStore( useCallback( (state) => { if (!pendingNameHost) return null; - return state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? pendingNameHost.hostname ?? null; + return ( + state.sessions[pendingNameHost.serverId]?.serverInfo?.hostname ?? + pendingNameHost.hostname ?? + null + ); }, - [pendingNameHost] - ) + [pendingNameHost], + ), ); useEffect(() => { @@ -561,7 +573,7 @@ export default function SettingsScreen() { } return predicate(); }, - [] + [], ); const handleEditDaemon = useCallback((profile: HostProfile) => { @@ -614,33 +626,36 @@ export default function SettingsScreen() { pendingEditReopenServerId, ]); - const handleSaveEditDaemon = useCallback(async (nextLabelRaw: string) => { - if (!editingServerId) return; - if (isSavingEdit) return; + const handleSaveEditDaemon = useCallback( + async (nextLabelRaw: string) => { + if (!editingServerId) return; + if (isSavingEdit) return; - const nextLabel = nextLabelRaw.trim(); - if (!nextLabel) { - Alert.alert("Label required", "Enter a label for this host."); - return; - } + const nextLabel = nextLabelRaw.trim(); + if (!nextLabel) { + Alert.alert("Label required", "Enter a label for this host."); + return; + } - try { - setIsSavingEdit(true); - await renameHost(editingServerId, nextLabel); - handleCloseEditDaemon(); - } catch (error) { - console.error("[Settings] Failed to rename host", error); - Alert.alert("Error", "Unable to save host"); - } finally { - setIsSavingEdit(false); - } - }, [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost]); + try { + setIsSavingEdit(true); + await renameHost(editingServerId, nextLabel); + handleCloseEditDaemon(); + } catch (error) { + console.error("[Settings] Failed to rename host", error); + Alert.alert("Error", "Unable to save host"); + } finally { + setIsSavingEdit(false); + } + }, + [editingServerId, handleCloseEditDaemon, isSavingEdit, renameHost], + ); const handleRemoveConnection = useCallback( async (serverId: string, connectionId: string) => { await removeConnection(serverId, connectionId); }, - [removeConnection] + [removeConnection], ); const handleRemoveDaemon = useCallback((profile: HostProfile) => { @@ -667,7 +682,7 @@ export default function SettingsScreen() { UnistylesRuntime.setTheme(newTheme); } }, - [updateSettings] + [updateSettings], ); const restartConfirmationMessage = @@ -714,7 +729,10 @@ export default function SettingsScreen() { - + {/* Host Management */} @@ -770,8 +788,8 @@ export default function SettingsScreen() { router.push({ pathname: "/pair-scan", params: targetServerId - ? { source, targetServerId, sourceServerId } - : { source, sourceServerId }, + ? { source, targetServerId, sourceServerId } + : { source, sourceServerId }, }); }} /> @@ -912,10 +930,7 @@ export default function SettingsScreen() { {isDesktop ? : null} {isDesktop ? ( - + ) : null} @@ -969,7 +984,11 @@ interface HostDetailModalProps { onRemoveHost: (host: HostProfile) => void; onAddConnection: () => void; restartConfirmationMessage: string; - waitForCondition: (predicate: () => boolean, timeoutMs: number, intervalMs?: number) => Promise; + waitForCondition: ( + predicate: () => boolean, + timeoutMs: number, + intervalMs?: number, + ) => Promise; isScreenMountedRef: MutableRefObject; } @@ -988,7 +1007,11 @@ function HostDetailModal({ }: HostDetailModalProps) { const { theme } = useUnistyles(); const [draftLabel, setDraftLabel] = useState(""); - const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ serverId: string; connectionId: string; title: string } | null>(null); + const [pendingRemoveConnection, setPendingRemoveConnection] = useState<{ + serverId: string; + connectionId: string; + title: string; + } | null>(null); const [isRemovingConnection, setIsRemovingConnection] = useState(false); // Read per-connection probes from host runtime snapshots. @@ -1000,7 +1023,9 @@ function HostDetailModal({ const isConnected = useHostRuntimeIsConnected(host?.serverId ?? ""); const runtime = getHostRuntimeStore(); const daemonClient = runtimeClient; - const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null); + const daemonVersion = useSessionStore((state) => + host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null, + ); const probeByConnectionId = runtimeSnapshot?.probeByConnectionId ?? new Map(); const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting"; const activeConnection = runtimeSnapshot?.activeConnection ?? null; @@ -1028,7 +1053,7 @@ function HostDetailModal({ if (!reconnected && host) { Alert.alert( "Unable to reconnect", - `${host.label} did not come back online. Please verify it restarted.` + `${host.label} did not come back online. Please verify it restarted.`, ); } } @@ -1040,23 +1065,21 @@ function HostDetailModal({ if (!isHostConnected()) { Alert.alert( "Host offline", - "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting." + "This host is offline. Paseo reconnects automatically—wait until it's back online before restarting.", ); return; } setIsRestarting(true); - void daemonClient - .restartServer(`settings_daemon_restart_${host.serverId}`) - .catch((error) => { - console.error(`[Settings] Failed to restart daemon ${host.label}`, error); - if (!isScreenMountedRef.current) return; - setIsRestarting(false); - Alert.alert( - "Error", - "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online." - ); - }); + void daemonClient.restartServer(`settings_daemon_restart_${host.serverId}`).catch((error) => { + console.error(`[Settings] Failed to restart daemon ${host.label}`, error); + if (!isScreenMountedRef.current) return; + setIsRestarting(false); + Alert.alert( + "Error", + "Failed to send the restart request. Paseo reconnects automatically—try again once the host shows as online.", + ); + }); void waitForDaemonRestart(); }, [daemonClient, host, isHostConnected, isScreenMountedRef, waitForDaemonRestart]); @@ -1065,7 +1088,7 @@ function HostDetailModal({ if (!daemonClient || !host) { Alert.alert( "Host unavailable", - "This host is not connected. Wait for it to come online before restarting." + "This host is not connected. Wait for it to come online before restarting.", ); return; } @@ -1076,15 +1099,17 @@ function HostDetailModal({ confirmLabel: "Restart", cancelLabel: "Cancel", destructive: true, - }).then((confirmed) => { - if (!confirmed) { - return; - } - beginServerRestart(); - }).catch((error) => { - console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error); - Alert.alert("Error", "Unable to open the restart confirmation dialog."); - }); + }) + .then((confirmed) => { + if (!confirmed) { + return; + } + beginServerRestart(); + }) + .catch((error) => { + console.error(`[Settings] Failed to open restart confirmation for ${host.label}`, error); + Alert.alert("Error", "Unable to open the restart confirmation dialog."); + }); }, [beginServerRestart, daemonClient, host, restartConfirmationMessage]); // Status display @@ -1113,7 +1138,8 @@ function HostDetailModal({ }); })(); const versionBadgeText = formatDaemonVersionBadge(daemonVersion); - const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; + const connectionError = + typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const handleDraftLabelChange = useCallback((nextValue: string) => { setDraftLabel(nextValue); @@ -1195,9 +1221,12 @@ function HostDetailModal({ latencyLoading={!probe || probe.status === "pending"} latencyError={probe?.status === "unavailable"} onRemove={() => { - const title = - formatHostConnectionLabel(conn); - setPendingRemoveConnection({ serverId: host.serverId, connectionId: conn.id, title }); + const title = formatHostConnectionLabel(conn); + setPendingRemoveConnection({ + serverId: host.serverId, + connectionId: conn.id, + title, + }); }} /> ); @@ -1216,14 +1245,20 @@ function HostDetailModal({ ) : null} {/* Save/Cancel + Advanced */} - - + + [ - styles.advancedTrigger, - pressed && { opacity: 0.85 }, - ]} + style={({ pressed }) => [styles.advancedTrigger, pressed && { opacity: 0.85 }]} > Advanced @@ -1231,7 +1266,9 @@ function HostDetailModal({ } + leading={ + + } status={isRestarting ? "pending" : "idle"} pendingLabel="Restarting..." disabled={!daemonClient || !isConnected} @@ -1239,7 +1276,9 @@ function HostDetailModal({ Restart daemon { if (host) onRemoveHost(host); }} + onSelect={() => { + if (host) onRemoveHost(host); + }} leading={} > Remove host @@ -1248,12 +1287,7 @@ function HostDetailModal({ -