chore: drive linter to zero, promote warn -> error in CI (#545)

* chore(lint): use Set for LOG_FORMATS membership check

* chore(lint): convert type aliases to interfaces (autofix)

- Remove no-use-before-define rule (conflicts with unistyles ordering)
- Add typescript/consistent-type-definitions: interface
- Run oxlint --fix: 606 type->interface conversions across 268 files

Typecheck green. Warnings: 5432 -> 3787 (-1645).

* chore(lint): clean up relay package warnings

* chore(lint): clean up cli package warnings

* chore(lint): flatten nested ternaries in server (no-nested-ternary)

* chore(lint): escape entities and hoist inline objects in website

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* fix(types): restore Record assignability after type->interface autofix

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): clean up desktop and highlight packages

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): hoist inline arrays in app (jsx-no-new-array-as-prop)

* chore(lint): format keyboard-shortcuts-section

* chore(lint): rename shadowed bindings in server (no-shadow)

Rename inner bindings that shadow outer imports or function params:
- Promise executor `resolve` -> `resolvePromise` (shadowed path `resolve`)
- Method params `options` -> `input`/`target`/`update`/`runOptions`/`opts`/`killOptions`
- Misc loop/destructure renames for `workspaceId`, `scriptNames`, `path`, `query`, `taskNotificationItem`

Mechanical change only; no behavior change.

* fix(types): add index signatures for Record assignability

* chore(lint): extract nested callbacks in server (max-nested-callbacks)

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

Work in progress: 30 of 369 warnings fixed across 23 files.

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

* chore(lint): reduce cyclomatic complexity in server

* chore(lint): hoist inline callbacks in app components (jsx-no-new-function-as-prop)

* chore(lint): parallelize safe await-in-loop in server

* chore(lint): hoist inline callbacks in app (jsx-no-new-function-as-prop)

Finish eliminating jsx-no-new-function-as-prop warnings in
agent-status-bar.tsx and complete refactor of git-diff-pane.tsx
by extracting per-item components and using stable useCallback
handlers.

* chore(lint): parallelize more safe await-in-loop in server

* fix(server): restore microtask ordering in message dispatch and stream event handler

The complexity refactor added async dispatcher chains that inserted extra
microtasks before message handlers ran, and wrapped the stream event switch
in an await that fired between emitState and dispatchStream. Two tests
regressed on both counts. Route to the matching dispatcher synchronously
and skip the await when the stream handler has no async work.

* chore(lint): hoist/memoize inline arrays and objects in app JSX

* chore(lint): parallelize safe awaits in server (no-await-in-loop)

Converts three sequential `for ... await` loops to `Promise.all`:
- OpenCode: configure MCP servers in parallel
- Sherpa: download model files and ensure multiple models concurrently
- Speech runtime: check required model files across models in parallel

* chore(lint): clear website warnings to zero

Resolves all 61 oxlint warnings in packages/website/ by hoisting inline
callbacks/JSX, extracting sub-components to satisfy jsx-max-depth, using
stable data-derived keys, adding explicit button types, flattening nested
ternaries, and removing unused code.

* chore(lint): parallelize more safe awaits in server (no-await-in-loop)

- Workspace reconciliation: archive missing workspaces and orphaned
  projects concurrently instead of sequentially.
- Directory suggestions: resolve child directory candidates in parallel
  before filtering.

* chore(lint): allow css imports in import/no-unassigned-import, hoist host page styles

* chore(lint): hoist dictation-controls inline arrays

* fix(server): restore microtask ordering in session message dispatch

Converts per-group dispatchers to Promise<void> | undefined. The complexity
refactor broke two tests by inserting extra microtasks between each
dispatcher's await. The new pattern routes synchronously via a nullish-
coalescing chain and awaits only the matching dispatcher's promise.

* chore(lint): flatten nested ternaries in button/volume-meter/autocomplete

* chore(lint): narrow explicit any in misc server files

* chore(lint): flatten nested ternaries in host-runtime/host-page/providers

* chore(lint): clear desktop warnings to zero

Convert polling loops to recursive helpers to avoid no-await-in-loop,
and switch the ws import to the named export so the WebSocket type is
referenced directly.

* chore(lint): remove useless constructors in server tests

* chore(lint): hoist pair-scan inline arrays and objects

Extracts corner style arrays and barcode scanner settings to module
scope, memoizes insets-dependent body/helper text styles.

* chore(lint): hoist inline style arrays in question-form-card

* chore(lint): hoist/memoize inline styles in plan-card

Extracts markdown rule style arrays into dedicated MarkdownInlineText,
MarkdownListItemContent, and MarkdownParagraph subcomponents that
memoize their own style arrays. Memoizes PlanCard container/title/
description styles.

* chore(lint): narrow explicit any in server relay/loader/logger-likes

* chore(lint): hoist SheetBackground combined style

* chore(lint): hoist inline styles in screen-header

* chore(lint): hoist inline styles/objects in menu-header

* chore(lint): hoist top-level inline arrays in diff-viewer

* chore(lint): narrow explicit any in codex-app-server-agent thread items

* chore(lint): reduce max-depth in claude-sdk-behavior test

* chore(lint): memoize inline styles in command-center

* chore(lint): extract helper to reduce max-depth in agent-response-loop

* chore(lint): flatten nested conditions in pi-direct-agent history

* chore(lint): reduce max-depth in claude-agent query pump

* chore(lint): clear cli warnings to zero

Refactor polling loops into recursive helpers to satisfy
no-await-in-loop, fix WebSocket named import, and correct
an absolute-path import in tests/tmp.

* chore(lint): memoize inline styles in context-menu

* chore(lint): narrow explicit any in claude-agent/openai stt

* chore(lint): reduce max-depth in opencode-agent foreground loop

* chore(lint): memoize inline styles in explorer-sidebar

* chore(lint): memoize mobile sidebar styles in explorer-sidebar

* chore(lint): memoize inline styles in agent-list

* chore(lint): reduce relay test no-await-in-loop warnings

Refactor while-loops and for-retry loops in e2e.test.ts and
live-relay.e2e.test.ts into recursive poll/attempt helpers.
Remaining 8 warnings in encrypted-channel.ts concern the custom
Transport interface (on-handler slots, not DOM EventTarget) and a
sequential send loop; both reflect deliberate runtime contracts.

* chore(lint): fix typecheck errors from hoisting refactors

- pair-scan: type BARCODE_SCANNER_SETTINGS as BarcodeSettings
- explorer-sidebar: add missing desktopSidebarStyle useMemo
- sidebar-workspace-list: coerce null dotColor to transparent
- menu-header, e2e.test: formatting

* chore(lint): memoize inline styles in workspace-screen

* chore(lint): memoize inline styles in workspace-desktop-tabs-row

* chore(lint): hoist constant style arrays in desktop-updates-section

* chore(lint): memoize inline styles in combobox

* chore(lint): extract SplitGroupChild to memoize inline styles

* chore(lint): memoize inline styles in dropdown-menu

* chore(lint): memoize inline styles in workspace-hover-card

* chore(lint): memoize inline styles in autocomplete, message-input, composer

* chore(lint): remove explicit any in server (129 warnings)

- session.ts: catch(error: any) -> catch(error) with Error coercion at use
- daemon-client transport: any -> unknown in listener types
- sherpa/onnx loaders: introduce structural native types
- pocket-tts-onnx: typed ONNX session inputs/outputs/tensors
- tests: any -> unknown + named stub types

* chore(lint): hoist inline arrays and objects in app (145 warnings)

- Hoist static style arrays/objects to module-level consts
- Memoize dynamic ones with useMemo and correct deps
- jsx-no-new-array-as-prop: 145 -> 43
- jsx-no-new-object-as-prop: 64 -> 21

* chore(lint): memoize inline styles in _layout

Fixes 3 react-perf/jsx-no-new-object-as-prop warnings in root layout by
memoizing the stack screen options, the agent screen override, and the
GestureHandlerRootView root style.

* chore(lint): memoize inline styles and objects in stream/status panes

Clears 10 react-perf/jsx-no-new warnings across welcome-screen,
file-explorer-pane, terminal-pane, agent-stream-view, and
agent-status-bar by extracting per-item subcomponents, hoisting constant
style tuples, and memoizing derived arrays/objects.

* chore(lint): memoize inline style objects in list/pane components

Clears 6 react-perf/jsx-no-new-object-as-prop warnings across
synced-loader, stream-strategy-web, sortable-inline-list, file-pane, and
both draggable-list platform variants by memoizing or hoisting their
inline style objects.

* chore(lint): hoist test fixture arrays/objects

Clears 14 react-perf/jsx-no-new-* warnings across app test files by
hoisting constant fixtures to module scope or wrapping them in helper
factories so they no longer appear as inline JSX prop values.

* chore(lint): memoize inline styles in message components

Clears 5 react-perf/jsx-no-new-* warnings in message.tsx by memoizing
image source objects, extracting an AssistantMessageBlockContainer for
per-block spacing, and moving todo list item rendering into a
TodoListItemRow subcomponent so each row memoizes its own style arrays.
The remaining 8 warnings in react-native-markdown-display render rules
are inherent to that library's rule API.

* chore(lint): remove unused vitest imports in server tests

* chore(lint): remove unused helpers and vars in server tests

* chore(lint): add explicit returns in then() callbacks in server

* chore(lint): fix no-shadow and no-nested-ternary in server

* chore(lint): flatten nested blocks to satisfy max-depth in server

* chore(lint): extract helpers to satisfy max-depth in server

* chore(lint): parallelize Claude persisted agents lookup

Replace sequential parseClaudeSessionDescriptor loop with Promise.all
to fix no-await-in-loop warning.

* chore(lint): parallelize Linux watch directory traversal

Switch BFS to level-by-level Promise.all over readdir calls, replacing
the sequential pop/await loop that triggered no-await-in-loop.

* chore(lint): parallelize workspace registry bootstrap upserts

Collect per-workspace upsert inputs in a sync pass, then run registry
writes via Promise.all to eliminate no-await-in-loop warnings.

* chore(lint): parallelize test daemon cleanup rm calls

Run the paseoHomeRoot/staticDir removals concurrently in close() and
the startup catch block to fix no-await-in-loop.

* chore(lint): mechanical cleanup in app (unused, shadow, nested-ternary)

Recovers in-flight edits from the app mechanical agent that couldn't be
pushed due to concurrent tree contention. Removes unused helpers and
locals, flattens nested ternaries, narrows a few props where unused.

* chore(lint): parallelize pending permission approvals

approvePendingPermissions now filters and records handled IDs in a
sync pass, then awaits all allowPermission calls concurrently instead
of looping awaits.

* chore(lint): parallelize rebase head-name lookup

Try both rebase backends (rebase-merge, rebase-apply) concurrently
via Promise.all and return the first non-null result.

* chore(lint): no-unused-vars in app

* chore(lint): parallelize worktree terminal bootstrap

Start each bootstrap terminal concurrently via Promise.all. Since every
spec creates an independent terminal and returns a standalone result,
parallelization preserves order while removing two no-await-in-loop
warnings.

* chore(lint): parallelize worktree bootstrap test cleanup and script spawn

Run terminal cleanup across managers and spawnWorkspaceScript for api
and web concurrently to eliminate no-await-in-loop warnings while
preserving semantics.

* chore(lint): prefer-add-event-listener in app

* chore(lint): parallelize script-health-monitor afterEach and spawns

Close all stub TCP servers concurrently and spawn typecheck/api
scripts via Promise.all to drop two no-await-in-loop warnings.

* chore(lint): jsx-no-useless-fragment in app

* chore(lint): parallelize executable PATH probing

* chore(lint): parallelize client and relay transport test cleanup

* chore(lint): parallelize agent storage record file probing

* chore(lint): no-array-index-key in app

* chore(lint): parallelize bootstrap provider availability cleanup

* chore(lint): parallelize Codex rollout file search

* chore(lint): parallelize dictation wav fixture search

* chore(lint): parallelize Claude session id fixture test

* chore(lint): parallelize script health monitor server cleanup

* chore(lint): parallelize Codex skills directory scan

* chore(lint): promise/always-return in app

* chore(lint): no-shadow in use-dictation, hoist icons in splash screen

* chore(lint): no-shadow in composer callbacks

* chore(format): apply oxfmt to 3 server files

* chore(lint): no-explicit-any in stt-manager/chat-mentions tests

* chore(lint): no-shadow in small app files

Rename inner shadowing identifiers in:
- add-host-modal, agent-status-bar, sidebar-workspace-list, terminal-pane
- workspace-setup-dialog, session-context, use-is-local-daemon
- setup-panel, new-workspace-screen, workspace-desktop-tabs-row

* chore(lint): no-explicit-any in claude-agent.test

* chore(lint): no-shadow in small app test/e2e files

* chore(lint): no-explicit-any in workspace-git-service.test

* chore(lint): no-shadow in app test hoisted blocks

* chore(lint): no-explicit-any in websocket-server.runtime-metrics.test

* chore(lint): no-explicit-any in acp-agent.test

* chore(lint): no-shadow in app composer/message-input/e2e helpers

* chore(lint): no-explicit-any in session.workspace-git-watch.test

* chore(lint): no-explicit-any in websocket-server.notifications.test

* chore(lint): no-shadow in sidebar-workspace-list.test, use-pr-pane-data.test

* chore(lint): no-explicit-any in websocket-server.relay-reconnect.test

* chore(lint): no-explicit-any in codex-app-server-agent.test

* chore(lint): no-shadow in composer.test, host-runtime.test, new-workspace-screen.test

* chore(lint): no-explicit-any in codex-app-server-agent.features.test

* chore(lint): hoist jsx-as-prop in app components batch 1

Memoize inline JSX icons passed as props across add-host-modal,
branch-switcher, composer, pair-link-modal, pr-pane, and
sidebar-workspace-list to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): no-explicit-any in session.test

* chore(lint): hoist jsx-as-prop in app components batch 2

Memoize inline JSX passed as props across agent-list, agent-status-bar,
file-explorer-pane, git-actions-split-button, and message to satisfy
react-perf/jsx-no-jsx-as-prop.

* chore(lint): hoist jsx-as-prop in app components batch 3

Memoize inline JSX passed as props across combined-model-selector,
draggable-list.native, provider-diagnostic-sheet, and
workspace-setup-dialog to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): hoist jsx-as-prop in desktop components

Memoize inline JSX passed as props across desktop-permissions-section,
desktop-updates-section, integrations-section, and pair-device-section
to satisfy react-perf/jsx-no-jsx-as-prop.

* chore(lint): no-explicit-any in mcp-server.test

* chore(lint): hoist jsx-as-prop in workspace/new-workspace screens

* chore(lint): hoist jsx-as-prop in remaining screens

* chore(lint): no-explicit-any in session.workspaces.test

* chore(lint): no-explicit-any in snapshot-mutation-ownership.test

* chore(lint): no-explicit-any in dictation-stream-manager.test

* chore(lint): no-explicit-any in relay-transport.e2e.test

* chore(lint): no-explicit-any in worktree-session.test

* chore(lint): no-explicit-any in model-resolver.test

* chore(lint): no-explicit-any in sherpa-parakeet-stt.test

* chore(lint): no-explicit-any in speech-download.e2e.test

* chore(lint): no-explicit-any in script-health-monitor.test

* chore(lint): no-explicit-any in schedule/service.test

* chore(lint): no-explicit-any in persistence-hooks.test

* chore(lint): no-explicit-any in editor-targets.test

* chore(lint): no-explicit-any in send-while-running-stuck-test-utils.test

* chore(lint): no-explicit-any in agent-storage.test

* chore(lint): no-nested-ternary in app batch 1 (18 files)

* chore(lint): no-explicit-any in generate-sherpa-tts-matrix

* chore(lint): no-explicit-any in claude-agent

* chore(lint): no-explicit-any in websocket-server

* chore(lint): no-explicit-any in process-conversation

* chore(lint): no-explicit-any in daemon-client

* chore(lint): no-explicit-any in codex-app-server-agent

* chore(lint): no-nested-ternary in app batch 2 (26 files)

* chore(format): apply oxfmt to server test files

* chore(lint): parallelize agent archiving in test-mcp-inject

* chore(lint): no-explicit-any in small app files (batch 1)

* chore(lint): no-explicit-any in small app files (batch 2)

* chore(lint): no-explicit-any in app (batch 3)

Covers use-web-scrollbar, stream-strategy, dictation-stream-sender test,
terminal-perf helper, checkout-git-actions-store test, and
web-desktop-scrollbar.

* chore(lint): no-explicit-any in app (batch 4)

Covers composer, message-input, tooltip, workspace-desktop-tabs-row,
and the e2e helpers (app, node-ws-factory, terminal-probes).

* chore(lint): no-explicit-any in polyfills/crypto.ts

* chore(lint): no-explicit-any in components/sidebar-workspace-list.tsx

* chore(lint): prefer-array-find over filter().at/pop

Replace filter(pred).at(-1)/pop() patterns with findLast(pred) and
filter(pred)[0] with find(pred) across server and app.

* chore(lint): no-explicit-any in components/message.tsx

* chore(lint): no-explicit-any in components/plan-card.tsx

* chore(lint): no-map-spread replace with Object.assign

Replace { ...obj, override } inside map callbacks with Object.assign({}, obj, { override })
to satisfy oxc/no-map-spread. Preserves copy-on-write semantics.

* chore(lint): no-extraneous-class in test mocks

Add dispose() stubs to xterm addon mocks to satisfy typescript-eslint/no-extraneous-class, and convert static-only Notification mocks to plain objects.

* chore(lint): no-named-as-default use named imports for ws and openai

* chore(lint): jsx-no-new-array-as-prop hoist composed style in volume-display

Memoize the style array so it is not created inline on every render.

* chore(lint): prefer-add-event-listener use Object.assign for non-DOM handlers

These transports (Transport interface, StreamableHTTPServerTransport) use
plain on<event> properties rather than EventTarget, so addEventListener is
not an option. Using Object.assign to set the handlers avoids the lint
false-positive without changing behavior.

* chore(lint): no-multiple-resolved null pendingResolve after settling

Replace boolean-settled guards with a nullable captured resolve/reject.
Each promise callback captures the resolver, nulls it on first use, and
calls it. This preserves the existing single-resolution semantics while
making the no-multiple-resolved rule happy.

* chore(lint): reduce structural complexity in server

Extract nested-callback cleanup helpers in worktree-bootstrap.test. Extract Codex model definition builder to drop list-models complexity below 20.

* chore(lint): extract helpers in agent-activity and session-store-hooks.test

Split tool-call update handling in groupActivities into helpers to clear max-depth. Hoist useWorkspaceFields selector in the hooks test to drop callback nesting.

* chore(lint): rules-of-hooks and exhaustive-deps in agent-stream-view

Move useMemo calls for permission card styles above the early return
so hooks run unconditionally. Add missing dependencies to the inline
path press handler and the stream render callback.

* chore(lint): rules-of-hooks in diff-scroll via optional context hook

Replace try/catch around useExplorerSidebarAnimation with a new
useExplorerSidebarAnimationOptional hook that returns null when the
provider is absent.

* chore(lint): rules-of-hooks and exhaustive-deps in sidebar-workspace-list

Replace conditional useMemo calls with plain inline arrays, capture
creatingWorkspaceTimeoutsRef.current in the effect body before cleanup,
and depend on the full input object in armTimers.

* chore(lint): rules-of-hooks in split-container

Hoist useWorkspaceLayoutStore and useMemo calls above the pane-kind
early return so hooks always run in the same order.

* chore(lint): rules-of-hooks in context-menu and dropdown-menu

Inline the resolvedWidthStyle object inside the content useMemo and move
the early return below the hook so it is always invoked in the same order.

* chore(lint): rules-of-hooks in web-desktop-scrollbar

Move thumbRegionStyle and handleStyle useMemo calls above the visibility
early return.

* chore(lint): reduce complexity in types/stream

Split reduceStreamUpdate timeline branch into per-case helpers (tool call and compaction) to clear complexity and max-depth warnings.

* chore(lint): reduce complexity in diff-highlighter

Extract metadata detection, path extraction, hunk parsing, and content-line push into helpers to drop parseDiff complexity below 20.

* chore(lint): exhaustive-deps in composer and use-attachment-preview-url

Memoize githubSearchItems so the picker callbacks don't see a new array
every render, hoist realtimeVoiceButtonStyle above rightContent useMemo
that depends on it, and add missing style dependencies. Tighten
use-attachment-preview-url to read the attachment via a ref while keying
off stable field primitives.

* chore(lint): reduce complexity in tool-call-detail-state

Extract per-detail helpers for hasMeaningfulToolCallDetail to clear complexity warning.

* chore(lint): reduce complexity in agent-grouping

Split groupAgents into partition, project-activity map, active-project, and inactive-date helpers.

* chore(lint): reduce max-depth in voice-runtime

Extract retireFinishedGroup helper to flatten processPlaybackQueue nesting.

* chore(lint): reduce max-depth in use-branch-switcher

Extract maybeRestoreStashForBranch callback to flatten handleBranchSelect nesting.

* chore(lint): exhaustive-deps in components batch

Address exhaustive-deps warnings across file-explorer-pane, file-pane,
git-diff-pane, message, provider-diagnostic-sheet, stream-strategy-web,
terminal-emulator, terminal-pane, and volume-meter. Memoize values that
otherwise changed every render, read non-reactive values through refs
when the effect intentionally tracks a different key, and add missing
dependencies where they were genuinely absent.

* chore(lint): reduce complexity/max-depth in draft-store

Extract collectQueuedMessageAttachmentIds and collectStreamUserImageIds helpers from runAttachmentGc. Extract buildMigratedDraftRecord helper for migratePersistedState.

* chore(lint): reduce complexity in tooltip and workspace-scripts-button

Extract resolveActualSide and resolveAlignedCoordinate helpers in tooltip. Extract resolveScriptIconColor in workspace-scripts-button.

* chore(lint): exhaustive-deps in contexts and hooks batch

Fixes exhaustive-deps warnings across app contexts and hooks by adding
missing deps, stabilizing derived arrays with useMemo, capturing ref values
at effect entry, and using ref pattern where deps were intentionally omitted.

* chore(lint): final hook-rules in workspace-screen, editor-button, stores, e2e

Memoizes derived arrays (availableEditors, terminals) to stabilize
useMemo deps, adds missing deps (normalizedServerId, normalizedWorkspaceId,
explorerToggleStyle, workspaceIdsKey, eventName), and renames the Playwright
fixture callback parameter from `use` to `provide` so eslint-plugin-react-hooks
doesn't conflate it with React's use() hook.

* chore(lint): reduce complexity/max-nested-callbacks in workspace-tabs-store

Extract migrate function body into migrateWorkspaceTabsState top-level
helper with per-loop sub-helpers (migrateUiTabsForKey, mergeExplicitTabOrder,
mergeLegacyTabOrder, migrateFocusedTabIds) and replace the inline IIFE in
ensureTab with buildNextTabsForEnsure helper.

* chore(lint): reduce complexity in session-store and panel-store

Extract isSessionServerInfoUnchanged helper from updateSessionServerInfo
in session-store. In panel-store split migrate body into per-version
migration helpers (migratePanelV2Explorer, migratePanelV3Explorer,
migratePanelExplorerTabByCheckout, migratePanelDesktopFocusMode) and
a top-level migratePanelState function.

* chore(lint): reduce complexity in keyboard-shortcuts and use-keyboard-shortcuts

Split resolveKeyboardShortcut into resolveInitialChordStep and
resolveAdvancingChordStep helpers with a shared buildMatchFromBinding
factory. Split handleAction's large switch into handleDispatchOnlyAction,
handlePayloadAction, handleSettingsToggle, and handleCommandCenterToggle
helpers, with hasPayloadKey type guard replacing inline payload checks.

* chore(lint): reduce complexity in audio recorder web hooks

Extract assertMicrophoneEnvironment helper from useAudioRecorder start
callback. Split useDictationAudioSource stop callback with
disconnectDictationAudioGraph, stopMediaRecorderIfActive,
finalizeRecorderStoppedPromise, and safeDisconnectNode helpers.

* chore(lint): reduce complexity in use-git-actions

* chore(lint): reduce complexity in use-pr-pane-data

* chore(lint): reduce complexity in use-agent-autocomplete

* chore(lint): reduce complexity in use-agent-screen-state-machine

* chore(lint): reduce complexity in session-stream-reducers

* chore(lint): reduce complexity in desktop-updates-section

* chore(lint): reduce complexity in pair-device-section

* chore(lint): reduce complexity in update-callout-source

* chore(lint): reduce complexity in provider-diagnostic-sheet

* chore(lint): disable no-await-in-loop (sequential-by-necessity cases)

Most remaining no-await-in-loop warnings were in legitimate sequential
patterns: polling loops with sleep, streaming/pagination cursors, shared
mutable state, ordered side effects (audio playback, port allocation).
Parallelizing these would change observable behavior. Rather than force
restructures that add complexity without benefit, disable the rule.

* chore(lint): reduce complexity in setup-panel

Extract helpers (resolveAutoExpandIndex, resolveSetupStatusLabel,
resolveCommandLog, buildCommandRowState) and lift the standalone log
view and top-level error banner into dedicated sub-components to drop
SetupPanel below the complexity limit.

* chore(lint): reduce complexity in AssistantMarkdownImage

Extract error-text resolution into a helper so the image component
drops below the cyclomatic complexity threshold.

* chore(lint): reduce complexity in ProjectLeadingVisual

Extract the status-variant dispatch into a ProjectLeadingVisualStatus
sub-component so the outer function stays below the cyclomatic
complexity limit.

* chore(lint): extract ChatAgentContent agent-building helper

* chore(lint): reduce complexity in ChatAgentContent

Extract the chat-agent selector (selectChatAgentState +
resolveChatAgentFromSession), the agent-shape constructor
(buildChatAgentFromState), and the not-found/error/boot view
dispatch (renderChatAgentNonReadyView) into helpers so both the
selector callback and the component function drop below the
cyclomatic complexity limit.

* chore(lint): extract subcomponents to reduce JSX nesting depth

* chore(lint): reduce complexity in app components (status-bar, explorer, setup-dialog, tool-call-details)

* chore(lint): reduce complexity in app hooks/runtime/stores/misc components

* chore(lint): long-tail cleanup (no-async-endpoint-handlers, unicorn rules, unescaped-entities, unassigned-import allowlist)

* chore(lint): simplify agent useMemo deps to match helper signature

* chore(lint): rename ChatService.postMessage to dispatchMessage

* chore(lint): server cleanup (no-shadow, no-useless-*, no-unmodified-loop, control-regex, require-yield)

* chore(lint): app cleanup (jsx-max-depth, jsx-no-new-*-as-prop, complexity, misleading-regex, no-useless-*)

* chore(lint): e2e cleanup (no-unmodified-loop-condition, max-nested-callbacks)

* chore(lint): workspace-screen complexity + no-empty-pattern cleanup

* chore(lint): use Array.from for defensive snapshot iteration

* chore(lint): final mechanical tail (control-regex, max-depth, useless-expressions, examples cleanup)

* chore(lint): reduce agent-stream-view complexity

* chore(lint): reduce resolveAgentModelSelection complexity

* chore(lint): reduce globalSetup complexity

* chore(format): oxfmt draft-store single-line signature

* chore(lint): reduce GitDiffPane complexity

* chore(lint): reduce MessageInput complexity

* chore(lint): reduce Combobox complexity

* fix(server): bound listLinuxWatchDirectories readdir concurrency

Wraps the per-level readdir traversal in listLinuxWatchDirectories with a
module-level p-limit (default 16, tunable via PASEO_LINUX_WATCH_READDIR_CONCURRENCY).
On broad repos, the previous Promise.all over an entire BFS level could issue
an unbounded number of concurrent readdir calls. Behavior is otherwise
identical: still traverses all non-.git directories and swallows per-directory
readdir failures.

* chore(lint): promote oxlint warnings to errors

- package.json: lint/lint:fix now pass --deny-warnings so any warning fails
- .github/workflows/ci.yml: add lint job running npm run lint
- .oxlintrc.json: disable no-empty-pattern for e2e/fixtures.ts
  (Playwright requires the empty object destructure as the first arg)
- packages/app/e2e/fixtures.ts: restore async ({}, provide) signature
- packages/app/e2e/global-setup.ts: oxfmt single-line signature

* fix(test): relay-transport ws mock exports WebSocket named binding

Commit 38efad7d switched relay-transport.ts from default to named import
of ws.WebSocket, but the test mock still only exported default. Updated
vi.mock("ws") to return both default and named WebSocket bindings so the
mocked module matches the new import shape. Classification: test drifted
with production code; test now exports what the production import needs.

* chore(lint): make oxlint error at config level; add Lefthook pre-commit

Replaces --deny-warnings workaround with semantic severity at the config
level, and adds whole-repo pre-commit gates via Lefthook.

- .oxlintrc.json: all enabled categories (correctness, suspicious, perf)
  and intentional rule overrides now use "error" instead of "warn".
  Removed duplicate "require-await" key (last-wins "off" preserved).
- package.json: lint scripts are plain oxlint / oxlint --fix. Adds
  lefthook devDep and "prepare": "lefthook install --force" so hooks
  install on npm install even where core.hooksPath is set globally.
- lefthook.yml: pre-commit runs format:check, lint, typecheck in
  parallel. Whole-repo gates (no glob filters). Block-only, no
  auto-formatting.

* fix: update lockfile signatures and Nix hash

* chore(lint): pre-commit runs formatter in write mode, then lint+typecheck

Switches the pre-commit hook from format:check to format (write) with
stage_fixed: true, so the formatter rewrites files and re-stages them
into the commit. Lint and typecheck then run in parallel against the
formatted tree.

Sequencing uses Lefthook 2.x jobs with a nested group: top-level jobs
run sequentially, so the format job completes before the checks group
starts; within checks, lint and typecheck run in parallel.

CI continues to use format:check, so unformatted code pushed with
--no-verify still fails on the server.

* chore(lint): revert pre-commit to format:check (no stage_fixed)

stage_fixed: true with a whole-repo formatter (oxfmt .) can silently
re-stage the full reformatted file when a developer used git add -p
to stage only a hunk, losing their hunk-level intent. Safer to just
block the commit on unformatted code and let the dev run
`npm run format` themselves.

Reverts to the simple parallel check form: all three commands run
concurrently and are read-only.

* chore(ts7): tsgo compatibility across the monorepo

Preserves tsc 5.9.3 behavior while making TypeScript 7 / tsgo
(@typescript/native-preview 7.0.0-dev.20260423.1) green
package-by-package.

tsconfig:
- server/tsconfig.server.json: drop removed `baseUrl`; rewrite
  `@server/*` paths entry to be relative to the tsconfig.
- website/tsconfig.json: drop removed `baseUrl`; rewrite
  `~/*` paths entry to be relative.
- expo-two-way-audio/tsconfig.json: replace inherited
  moduleResolution=node10 with `bundler`, set `module: esnext`
  (required by `bundler`). Matches Metro/Expo runtime behavior
  and avoids forcing `.js` extensions in source.

source:
- app/e2e/helpers/terminal-probes.ts: WebSocket subclass `send`
  now types its argument as `Parameters<WebSocket["send"]>[0]`
  so the subclass signature tracks the platform type across
  tsc 5.9 and TS 7 libdom.
- relay/src/crypto.ts: tweetnacl setPRNG callback allocates a
  fresh Uint8Array for getRandomValues (owning ArrayBuffer) and
  copies into the caller-provided view. TS 7 libdom requires
  ArrayBufferView<ArrayBuffer> (not ArrayBufferLike).

All 8 packages green under both tsc --noEmit and tsgo --noEmit.

* fix(app): update os-notifications test mocks for addEventListener

Commit 46731a51 switched attachWebClickHandler from notification.onclick
to notification.addEventListener("click", ...) to satisfy the
prefer-add-event-listener lint rule. The test mocks still asserted
the old onclick property, so two specs failed on CI with
"notification.addEventListener is not a function". Update the three
MockNotification classes to record click listeners through
addEventListener and drive them via clickListeners[0] in the
assertions.

* chore: switch typecheck from tsc to tsgo (@typescript/native-preview)

Installs @typescript/native-preview 7.0.0-dev.20260423.1 as a root
devDependency and swaps every per-workspace `typecheck` script from
`tsc --noEmit` to `tsgo --noEmit`. Build scripts still use `tsc`, so
emit behavior is unchanged. Monorepo typecheck drops from ~28s to
~4.7s wall clock (~6x faster).

expo-two-way-audio previously ran `tsc` without --noEmit for its
typecheck step. tsgo is stricter about rootDir on emit, so the
typecheck script explicitly passes --noEmit here too.

* fix: update lockfile signatures and Nix hash

* fix(app): commit *.css module declaration for CI typecheck

expo generates expo-env.d.ts (which references expo/types where
*.css is declared) locally, but the file is gitignored. CI does a
fresh checkout + install and doesn't run expo, so the declaration
is missing and tsgo rejects the side-effect import
'@xterm/xterm/css/xterm.css' with TS2882. Add a committed
packages/app/global.d.ts with 'declare module "*.css"' so typecheck
is self-contained.

tsc tolerated the missing declaration more loosely; tsgo is stricter.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Mohamed Boudra
2026-04-24 12:26:10 +08:00
committed by GitHub
parent 5b2f3fd830
commit 446dd9cfe6
605 changed files with 57634 additions and 43941 deletions

View File

@@ -24,6 +24,22 @@ jobs:
- name: Check formatting
run: npx oxfmt --check .
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm install
- name: Lint
run: npm run lint
typecheck:
runs-on: ubuntu-latest
steps:

View File

@@ -2,63 +2,88 @@
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
"categories": {
"correctness": "warn",
"suspicious": "warn",
"perf": "warn"
"correctness": "error",
"suspicious": "error",
"perf": "error"
},
"rules": {
"react/react-in-jsx-scope": "off",
"no-unused-expressions": "warn",
"no-useless-catch": "warn",
"preserve-caught-error": "warn",
"require-await": "warn",
"no-async-promise-executor": "warn",
"no-useless-escape": "warn",
"no-empty-pattern": "warn",
"no-self-assign": "warn",
"no-shadow": "warn",
"no-use-before-define": ["warn", { "functions": false, "classes": false, "variables": true }],
"no-await-in-loop": "off",
"no-unused-expressions": "error",
"no-useless-catch": "error",
"preserve-caught-error": "error",
"require-await": "off",
"no-async-promise-executor": "error",
"no-useless-escape": "error",
"no-empty-pattern": "error",
"no-self-assign": "error",
"no-shadow": "error",
"unicorn/consistent-function-scoping": "off",
"unicorn/no-array-sort": "off",
"unicorn/no-useless-spread": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-new-array": "warn",
"unicorn/no-empty-file": "warn",
"unicorn/no-useless-spread": "error",
"unicorn/no-useless-fallback-in-spread": "error",
"unicorn/no-new-array": "error",
"unicorn/no-empty-file": "error",
"promise/always-return": "warn",
"promise/no-multiple-resolved": "warn",
"promise/always-return": "error",
"promise/no-multiple-resolved": "error",
"react/no-array-index-key": "warn",
"react/jsx-no-useless-fragment": "warn",
"react/jsx-no-constructed-context-values": "warn",
"react/no-unescaped-entities": "warn",
"react/button-has-type": "warn",
"react/jsx-max-depth": ["warn", { "max": 6 }],
"react/no-array-index-key": "error",
"react/jsx-no-useless-fragment": "error",
"react/jsx-no-constructed-context-values": "error",
"react/no-unescaped-entities": "error",
"react/button-has-type": "error",
"react/jsx-max-depth": ["error", { "max": 6 }],
"react-hooks/rules-of-hooks": "warn",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
"react-perf/jsx-no-new-array-as-prop": "warn",
"react-perf/jsx-no-new-function-as-prop": "warn",
"react-perf/jsx-no-new-object-as-prop": "warn",
"react-perf/jsx-no-jsx-as-prop": "warn",
"react-perf/jsx-no-new-array-as-prop": "error",
"react-perf/jsx-no-new-function-as-prop": "error",
"react-perf/jsx-no-new-object-as-prop": "error",
"react-perf/jsx-no-jsx-as-prop": "error",
"oxc/no-map-spread": "warn",
"oxc/no-async-endpoint-handlers": "warn",
"oxc/only-used-in-recursion": "warn",
"oxc/no-map-spread": "error",
"oxc/no-async-endpoint-handlers": "error",
"oxc/only-used-in-recursion": "error",
"typescript/no-explicit-any": "warn",
"typescript/prefer-as-const": "warn",
"typescript/no-this-alias": "warn",
"typescript/no-explicit-any": "error",
"typescript/prefer-as-const": "error",
"typescript/no-this-alias": "error",
"typescript/consistent-type-definitions": ["error", "interface"],
"no-nested-ternary": "warn",
"no-unneeded-ternary": "warn",
"import/no-unassigned-import": [
"error",
{
"allow": [
"**/*.css",
"**/expo-router/entry",
"**/event-target-polyfill",
"**/dotenv/config",
"**/react-native",
"**/@/styles/unistyles",
"**/src/styles/unistyles",
"**/@/test/window-local-storage"
]
}
],
"complexity": ["warn", { "max": 20 }],
"max-depth": ["warn", { "max": 4 }],
"max-nested-callbacks": ["warn", { "max": 3 }]
}
"no-nested-ternary": "error",
"no-unneeded-ternary": "error",
"complexity": ["error", { "max": 20 }],
"max-depth": ["error", { "max": 4 }],
"max-nested-callbacks": ["error", { "max": 3 }]
},
"overrides": [
{
"files": ["**/e2e/fixtures.ts"],
"rules": {
"no-empty-pattern": "off"
}
}
]
}

9
lefthook.yml Normal file
View File

@@ -0,0 +1,9 @@
pre-commit:
parallel: true
commands:
format:
run: npm run format:check
lint:
run: npm run lint
typecheck:
run: npm run typecheck

View File

@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-Y2yfCV2x5mwRp3uTURqTi2b9c5n2LKXtJH2eswKsvFQ=";
npmDepsHash = "sha256-O5AxXbwGJO52gvmVTzJqgy7nb2dxoVAaikBni3Gtu/s=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).

306
package-lock.json generated
View File

@@ -21,9 +21,11 @@
],
"devDependencies": {
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"lefthook": "^2.1.6",
"oxfmt": "0.46.0",
"oxlint": "1.61.0",
"patch-package": "^8.0.1",
@@ -14261,6 +14263,147 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript/native-preview": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-9WD7TJJlGvt9PQqJI/+44dVP4oqGQFIkYrpXt7nlQ0WgNIErN52x/XhxmJ4nWft06qejgPiUbPo4aYRNOmIHXg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsgo": "bin/tsgo.js"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260423.1",
"@typescript/native-preview-darwin-x64": "7.0.0-dev.20260423.1",
"@typescript/native-preview-linux-arm": "7.0.0-dev.20260423.1",
"@typescript/native-preview-linux-arm64": "7.0.0-dev.20260423.1",
"@typescript/native-preview-linux-x64": "7.0.0-dev.20260423.1",
"@typescript/native-preview-win32-arm64": "7.0.0-dev.20260423.1",
"@typescript/native-preview-win32-x64": "7.0.0-dev.20260423.1"
}
},
"node_modules/@typescript/native-preview-darwin-arm64": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-wbLr6o5fROaCYt6cOpFhbe92FJAOdhAHwm/s8I/IyN5HbL1ULgel/wHaZiR+ws+27rgruNUiCENzTUg9vSz2bA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-darwin-x64": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-13MpNT+4MgkgrfiW2u03rnER5aB3yz9fA0bWEYh6IH3rIqA2AR3Dntp3QXW4sQrZf0SriXqHe2R7X3HCT5xmqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-arm": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-CxUA15qbPQRvz2nanBpiv1h4tgXTCJJwqOtgKMSdIuPkow8dyYW3ba5oLoH/jZhS4792XislX659hlFrfiU6CQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-arm64": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-ICIkJDTqmn0R4Vs811+Ht2RYTk1OCrAhHCu0JthmhR216T1Tqgi5DWRoCprp3RL1qU6fLnxxrIpEbNlNN7XFYA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-linux-x64": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-cWLFS4R8dOU1YuUJ/2VLeGMVIjgI3GGb/f9rRY5MbWHq5l3NNZh8y1QwAOrTh3+g3q6+znArfxVnD2hZHUz8Mw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-win32-arm64": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-OWaGUI4+dHqYZv+k6sITx9Y27FNy3XzNFk4OrOiYtBkIO/xrb9TPMP4A5XI4n5zwRLIv3xne9g039xgRbaeyoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/native-preview-win32-x64": {
"version": "7.0.0-dev.20260423.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260423.1.tgz",
"integrity": "sha512-5MQjO/qdLwXpjW7Dy/1lNv7Vtpvo6bhCkbjan4PoRN5/eeyqEqDWxdf8AGE4btLmHqyIjEHRuYf7kp2tlAr6lQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -27222,6 +27365,169 @@
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"license": "MIT"
},
"node_modules/lefthook": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook/-/lefthook-2.1.6.tgz",
"integrity": "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"lefthook": "bin/index.js"
},
"optionalDependencies": {
"lefthook-darwin-arm64": "2.1.6",
"lefthook-darwin-x64": "2.1.6",
"lefthook-freebsd-arm64": "2.1.6",
"lefthook-freebsd-x64": "2.1.6",
"lefthook-linux-arm64": "2.1.6",
"lefthook-linux-x64": "2.1.6",
"lefthook-openbsd-arm64": "2.1.6",
"lefthook-openbsd-x64": "2.1.6",
"lefthook-windows-arm64": "2.1.6",
"lefthook-windows-x64": "2.1.6"
}
},
"node_modules/lefthook-darwin-arm64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-2.1.6.tgz",
"integrity": "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/lefthook-darwin-x64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-2.1.6.tgz",
"integrity": "sha512-5Ka6cFxiH83krt+OMRQtmS6zqoZR5SLXSudLjTbZA1c3ZqF0+dqkeb4XcB6plx6WR0GFizabuc6Bi3iXPIe1eQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/lefthook-freebsd-arm64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-2.1.6.tgz",
"integrity": "sha512-VswyOg5CVN3rMaOJ2HtnkltiMKgFHW/wouWxXsV8RxSa4tgWOKxM0EmSXi8qc2jX+LRga6B0uOY6toXS01zWxA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/lefthook-freebsd-x64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-2.1.6.tgz",
"integrity": "sha512-vXsCUFYuVwrVWwcypB7Zt2Hf+5pl1V1la7ZfvGYZaTRURu0zF/XUnMF/nOz/PebGv0f4x/iOWXWwP7E42xRWsg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/lefthook-linux-arm64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-2.1.6.tgz",
"integrity": "sha512-WDJiQhJdZOvKORZd+kF/ms2l6NSsXzdA9ahflyr65V90AC4jES223W8VtEMbGPUtHuGWMEZ/v/XvwlWv0Ioz9g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/lefthook-linux-x64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-linux-x64/-/lefthook-linux-x64-2.1.6.tgz",
"integrity": "sha512-C18nCd7nTX1AVL4TcvwMmLAO1VI1OuGluIOTjiPkBQ746Ls1HhL5rl//jMPACmT28YmxIQJ2ZcLPNmhvEVBZvw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/lefthook-openbsd-arm64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-2.1.6.tgz",
"integrity": "sha512-mZOMxM8HiPxVFXDO3PtCUbH4GB8rkveXhsgXF27oAZTYVzQ3gO9vT6r/pxit6msqRXz3fvcwimLVJgb8eRsa8A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/lefthook-openbsd-x64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-2.1.6.tgz",
"integrity": "sha512-sG9ALLZSnnMOfXu+B7SmxFhJhuoAh4bqi5En5aaHJET48TqrLOcWWZuH+7ArFM6gr/U5KfSUvdmHFmY8WqCcIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/lefthook-windows-arm64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-2.1.6.tgz",
"integrity": "sha512-lD8yFWY4Csuljd0Rqs7EQaySC0VvDf7V3rN1FhRMUISTRDHutebIom1Loc8ckQPvKYGC6mftT9k0GvipsS+Brw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/lefthook-windows-x64": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-2.1.6.tgz",
"integrity": "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",

View File

@@ -38,6 +38,7 @@
"dev:app": "npm run start --workspace=@getpaseo/app",
"dev:website": "npm run dev --workspace=@getpaseo/website",
"postinstall": "node scripts/postinstall-patches.mjs",
"prepare": "lefthook install --force",
"build": "npm run build --workspaces --if-present",
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
@@ -89,9 +90,11 @@
},
"devDependencies": {
"@types/ws": "^8.5.14",
"@typescript/native-preview": "7.0.0-dev.20260423.1",
"concurrently": "^9.2.1",
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"lefthook": "^2.1.6",
"oxfmt": "0.46.0",
"oxlint": "1.61.0",
"patch-package": "^8.0.1",

View File

@@ -3,12 +3,12 @@ import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-r
// Extend base test to provide dynamic baseURL from global-setup
const test = base.extend({
baseURL: async ({}, use) => {
baseURL: async ({}, provide) => {
const metroPort = process.env.E2E_METRO_PORT;
if (!metroPort) {
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
}
await use(`http://localhost:${metroPort}`);
await provide(`http://localhost:${metroPort}`);
},
});
@@ -67,7 +67,7 @@ test.beforeEach(async ({ page }) => {
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
({ daemon, preferences, seedNonce: nonce }) => {
// `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.
@@ -75,13 +75,13 @@ test.beforeEach(async ({ page }) => {
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
// Hard-reset anything that could point to a developer's real daemon.
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));

View File

@@ -8,13 +8,13 @@ import { Buffer } from "node:buffer";
import dotenv from "dotenv";
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
type WaitForServerOptions = {
interface WaitForServerOptions {
host?: string;
timeoutMs?: number;
label: string;
childProcess?: ChildProcess | null;
getRecentOutput?: () => string;
};
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
@@ -125,16 +125,21 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
}
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
let pendingResolve: (() => void) | null = resolve;
const settle = () => {
if (!pendingResolve) return;
const fn = pendingResolve;
pendingResolve = null;
clearTimeout(timeout);
fn();
};
const timeout = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
resolve();
settle();
}, 5000);
child.once("exit", () => {
clearTimeout(timeout);
resolve();
});
child.once("exit", settle);
});
}
@@ -197,12 +202,12 @@ function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
return resolvePaseoHomePath(trimmed);
}
type OfferPayload = {
interface OfferPayload {
v: 2;
serverId: string;
daemonPublicKeyB64: string;
relay: { endpoint: string };
};
}
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
@@ -250,8 +255,10 @@ process.exit(1);
return binDir;
}
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, "");
return input.replace(ANSI_PATTERN, "");
}
function ensureRelayBuildArtifact(repoRoot: string): void {
@@ -328,68 +335,44 @@ async function waitForPairingOfferFromCli(args: {
);
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
interface DictationConfig {
openAiUsable: boolean;
localModelsDir: string | null;
}
async function loadEnvTestFile(repoRoot: string): Promise<void> {
const envTestPath = path.join(repoRoot, ".env.test");
if (existsSync(envTestPath)) {
dotenv.config({ path: envTestPath });
}
}
const port = await getAvailablePort();
let relayPort = 0;
const metroPort = await getAvailablePort();
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
async function applyPaseoHomeFork(targetHome: string): Promise<void> {
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
if (forkSourceHome) {
const forkResult = await forkPaseoHomeMetadata({
sourceHome: forkSourceHome,
targetHome: paseoHome,
});
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
console.log(
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
`${forkResult.copiedBytes} bytes)`,
);
if (forkResult.skippedMissing.length > 0) {
console.warn(
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
);
}
if (!forkSourceHome) {
return;
}
const forkResult = await forkPaseoHomeMetadata({
sourceHome: forkSourceHome,
targetHome,
});
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
console.log(
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
`${forkResult.copiedBytes} bytes)`,
);
if (forkResult.skippedMissing.length > 0) {
console.warn(
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
);
}
}
const cleanup = async () => {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
};
async function resolveDictationConfig(): Promise<DictationConfig> {
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
const defaultLocalModelsDir = path.join(
process.env.HOME ?? "",
@@ -412,213 +395,291 @@ export default async function globalSetup() {
console.log(
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
);
return { openAiUsable, localModelsDir };
}
interface RelayStreamState {
failureLine: string | null;
readyForSelectedPort: boolean;
}
function attachRelayStreamHandlers(
child: ChildProcess,
relayPort: number,
buffer: ReturnType<typeof createLineBuffer>,
state: RelayStreamState,
): void {
function handleChunk(data: Buffer, streamTag: "stdout" | "stderr") {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[${streamTag}] ${line}`);
const failure = parseRelayStartupFailure(line);
if (failure) {
state.failureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
state.readyForSelectedPort = true;
}
if (streamTag === "stdout") {
console.log(`[relay] ${line}`);
} else {
console.error(`[relay] ${line}`);
}
}
}
child.stdout?.on("data", (data: Buffer) => handleChunk(data, "stdout"));
child.stderr?.on("data", (data: Buffer) => handleChunk(data, "stderr"));
}
async function awaitRelayReady(
child: ChildProcess,
relayPort: number,
state: RelayStreamState,
buffer: ReturnType<typeof createLineBuffer>,
): Promise<void> {
await waitForServer(relayPort, {
label: "Relay dev server",
timeoutMs: 30000,
childProcess: child,
getRecentOutput: buffer.dump,
});
const readyDeadline = Date.now() + 5000;
function isRelayReadyCheckPending(): boolean {
if (state.readyForSelectedPort) return false;
if (state.failureLine !== null) return false;
if (child.exitCode !== null) return false;
if (child.signalCode !== null) return false;
if (Date.now() >= readyDeadline) return false;
return true;
}
while (isRelayReadyCheckPending()) await sleep(100);
if (state.failureLine) {
throw new Error(`Relay startup failed: ${state.failureLine}`);
}
if (!state.readyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
buffer.dump,
)}`,
);
}
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(
`Relay process exited before startup completed (exit code ${child.exitCode}, signal ${child.signalCode}).${formatRecentOutput(
buffer.dump,
)}`,
);
}
}
async function startRelay(): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePort();
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
attachRelayStreamHandlers(relayProcess, relayPort, buffer, state);
try {
await awaitRelayReady(relayProcess, relayPort, state, buffer);
return relayPort;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
);
}
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
const appDir = path.resolve(__dirname, "..");
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
child.stdout?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stdout] ${line}`);
console.log(`[metro] ${line}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
buffer.add(`[stderr] ${line}`);
console.error(`[metro] ${line}`);
}
});
return child;
}
interface DaemonSpawnArgs {
port: number;
relayPort: number;
metroPort: number;
paseoHome: string;
fakeGhBinDir: string;
dictation: DictationConfig;
buffer: ReturnType<typeof createLineBuffer>;
}
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
const tsxBin = execSync("which tsx").toString().trim();
const { openAiUsable, localModelsDir } = args.dictation;
const child = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: args.paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${args.port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
});
let stdoutBuffer = "";
child.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;
args.buffer.add(`[stdout] ${trimmed}`);
console.log(`[daemon] ${trimmed}`);
}
});
child.stderr?.on("data", (data: Buffer) => {
const lines = data
.toString()
.split("\n")
.filter((line) => line.trim());
for (const line of lines) {
args.buffer.add(`[stderr] ${line}`);
console.error(`[daemon] ${line}`);
}
});
return child;
}
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
await Promise.all([
stopProcess(daemonProcess),
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
daemonProcess = null;
metroProcess = null;
relayProcess = null;
if (paseoHome && shouldRemovePaseoHome) {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
} else if (paseoHome) {
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
}
export default async function globalSetup() {
const repoRoot = path.resolve(__dirname, "../../..");
ensureRelayBuildArtifact(repoRoot);
await loadEnvTestFile(repoRoot);
const port = await getAvailablePort();
const metroPort = await getAvailablePort();
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
fakeGhBinDir = await createFakeGhBin();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
await applyPaseoHomeFork(paseoHome);
const cleanup = () => performCleanup(shouldRemovePaseoHome);
const dictation = await resolveDictationConfig();
try {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let relayStarted = false;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
relayPort = await getAvailablePort();
relayLineBuffer = createLineBuffer();
let relayStartupFailureLine: string | null = null;
let relayReadyForSelectedPort = false;
relayProcess = spawn(
"npx",
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
{
cwd: relayDir,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
detached: false,
},
);
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);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.log(`[relay] ${line}`);
}
});
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);
if (failure) {
relayStartupFailureLine = failure;
}
const clean = stripAnsi(line);
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
if (readyMatch && Number(readyMatch[1]) === relayPort) {
relayReadyForSelectedPort = true;
}
console.error(`[relay] ${line}`);
}
});
try {
await waitForServer(relayPort, {
label: "Relay dev server",
timeoutMs: 30000,
childProcess: relayProcess,
getRecentOutput: relayLineBuffer.dump,
});
const readyDeadline = Date.now() + 5000;
while (
!relayReadyForSelectedPort &&
relayStartupFailureLine === null &&
relayProcess?.exitCode === null &&
relayProcess?.signalCode === null &&
Date.now() < readyDeadline
) {
await sleep(100);
}
if (relayStartupFailureLine) {
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
}
if (!relayReadyForSelectedPort) {
throw new Error(
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
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,
)}`,
);
}
relayStarted = true;
break;
} catch (error) {
lastRelayStartupError = error;
await stopProcess(relayProcess);
relayProcess = null;
}
}
if (!relayStarted) {
const message =
lastRelayStartupError instanceof Error
? lastRelayStartupError.message
: String(lastRelayStartupError);
throw new Error(
`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)], {
cwd: appDir,
env: {
...process.env,
BROWSER: "none", // Don't auto-open browser
},
stdio: ["ignore", "pipe", "pipe"],
detached: false,
const relayPort = await startRelay();
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
relayPort,
metroPort,
paseoHome,
fakeGhBinDir,
dictation,
buffer: daemonLineBuffer,
});
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());
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();
daemonProcess = spawn(tsxBin, ["src/server/index.ts"], {
cwd: serverDir,
env: {
...process.env,
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: paseoHome,
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}`,
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
...(openAiUsable
? {
PASEO_DICTATION_STT_PROVIDER: "openai",
PASEO_VOICE_STT_PROVIDER: "openai",
PASEO_VOICE_TTS_PROVIDER: "openai",
}
: {}),
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
NODE_ENV: "development",
},
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() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
daemonLineBuffer.add(`[stdout] ${trimmed}`);
console.log(`[daemon] ${trimmed}`);
}
});
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}`);
}
});
// Wait for both daemon and Metro to be ready
await Promise.all([
waitForServer(port, {
label: "Paseo daemon",
@@ -627,7 +688,7 @@ export default async function globalSetup() {
}),
waitForServer(metroPort, {
label: "Metro web server",
timeoutMs: 120000, // Metro can take longer to start
timeoutMs: 120000,
childProcess: metroProcess,
getRecentOutput: metroLineBuffer.dump,
}),

View File

@@ -7,22 +7,22 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const NEAR_BOTTOM_THRESHOLD_PX = 72;
export type ScrollMetrics = {
export interface ScrollMetrics {
offsetY: number;
contentHeight: number;
viewportHeight: number;
distanceFromBottom: number;
};
}
export type SeededAgent = {
export interface SeededAgent {
id: string;
title: string;
expectedTailText: string;
url: string;
workspaceUrl: string;
};
}
export type DaemonClientInstance = {
export interface DaemonClientInstance {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ export type DaemonClientInstance = {
}): Promise<{ id: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
};
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -84,12 +84,12 @@ export function createReplyTurn(label: string): {
};
}
type DaemonClientConfig = {
interface DaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (config: DaemonClientConfig) => DaemonClientInstance

View File

@@ -27,19 +27,19 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
}
const needsReset = await page.evaluate(
({ expectedEndpoint, expectedServerId }) => {
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
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 entry = parsed[0] as { serverId?: string; connections?: unknown };
if (entry?.serverId !== serverId) return true;
const connections = entry?.connections;
if (!Array.isArray(connections)) return true;
if (
connections.some(
(c: any) =>
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" &&
typeof c?.endpoint === "string" &&
/:6767\b/.test(c.endpoint),
@@ -47,7 +47,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
)
return true;
return !connections.some(
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === endpoint,
);
} catch {
return true;
@@ -68,10 +69,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
});
const preferences = buildCreateAgentPreferences(expectedServerId);
await page.evaluate(
({ daemon, preferences }) => {
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },
@@ -80,6 +81,68 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
await page.reload();
}
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
let registry: unknown;
try {
registry = JSON.parse(registryRaw);
} catch {
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"}).`,
);
}
const daemon = registry[0] as { serverId?: string; connections?: unknown };
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)}).`,
);
}
return { serverId: daemon.serverId, connections: daemon.connections };
}
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
if (
!Array.isArray(connections) ||
!connections.some(
(c: { type?: string; endpoint?: string }) =>
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
)
) {
throw new Error(
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
);
}
if (
Array.isArray(connections) &&
connections.some(
(c: { type?: string; endpoint?: string }) =>
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)}).`,
);
}
}
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
try {
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (prefs?.serverId !== serverId) {
throw new Error(
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${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.", {
cause: error,
});
}
}
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
const port = getE2EDaemonPort();
const expectedEndpoint = `127.0.0.1:${port}`;
@@ -98,68 +161,18 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
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.");
}
if (!Array.isArray(registry) || registry.length !== 1) {
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
if (serverId !== expectedServerId) {
throw new Error(
`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 (daemon.serverId !== expectedServerId) {
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)
) {
throw new Error(
`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)}).`,
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
);
}
assertDaemonConnections(connections, expectedEndpoint);
if (!snapshot.prefsRaw) {
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)}).`,
);
}
} catch (error) {
if (error instanceof Error) throw error;
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
cause: error,
});
}
assertPreferencesMatch(snapshot.prefsRaw, serverId);
}
export const gotoAppShell = async (page: Page) => {
@@ -310,7 +323,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
if (trimmedDirectory.startsWith("/private/var/")) {
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
}
const basename = trimmedDirectory.split("/").filter(Boolean).pop() ?? trimmedDirectory;
const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory;
await expect
.poll(
@@ -345,8 +358,8 @@ export const ensureHostSelected = async (page: Page) => {
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;
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
if (!Array.isArray(registry) || registry.length !== 1)
return { ok: false, reason: "registry shape" } as const;
const serverId = registry[0]?.serverId;

View File

@@ -11,13 +11,13 @@ import {
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
export type ArchiveTabAgent = {
export interface ArchiveTabAgent {
id: string;
title: string;
cwd: string;
};
}
type ArchiveTabDaemonClient = {
interface ArchiveTabDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
createAgent(options: {
@@ -36,7 +36,7 @@ type ArchiveTabDaemonClient = {
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
};
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -73,12 +73,12 @@ function buildSeededStoragePayload() {
};
}
type ArchiveTabDaemonClientConfig = {
interface ArchiveTabDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
@@ -148,21 +148,21 @@ export async function primeAdditionalPage(page: Page): Promise<void> {
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
});
await page.addInitScript(
({ daemon, preferences, seedNonce }) => {
({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
const disableValue = localStorage.getItem(disableOnceKey);
if (disableValue) {
localStorage.removeItem(disableOnceKey);
if (disableValue === seedNonce) {
if (disableValue === nonce) {
return;
}
}
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.removeItem("@paseo:settings");
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
},
{ daemon, preferences, seedNonce },
);
@@ -173,11 +173,11 @@ export async function resetSeededPageState(page: Page): Promise<void> {
const { daemon, preferences } = buildSeededStoragePayload();
await page.goto("/");
await page.evaluate(
({ daemon, preferences }) => {
({ daemon: seededDaemon, preferences: seededPreferences }) => {
localStorage.clear();
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
localStorage.removeItem("@paseo:settings");
},
{ daemon, preferences },

View File

@@ -91,7 +91,7 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(page: Page): Promise<void> {
export async function waitForLauncherPanel(_page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}

View File

@@ -17,21 +17,21 @@ type NewWorkspaceDaemonClient = Pick<
| "openProject"
>;
type NewWorkspaceDaemonClientConfig = {
interface NewWorkspaceDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
export type OpenedProject = {
export interface OpenedProject {
workspaceId: string;
projectKey: string;
projectDisplayName: string;
workspaceName: string;
};
}
function getDaemonPort(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;

View File

@@ -1,20 +1,20 @@
import WebSocket from "ws";
import { WebSocket } from "ws";
type WebSocketLike = {
interface WebSocketLike {
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on?: (event: string, listener: (...args: any[]) => void) => void;
off?: (event: string, listener: (...args: any[]) => void) => void;
removeListener?: (event: string, listener: (...args: any[]) => void) => void;
addEventListener?: (event: string, listener: (event: any) => void) => void;
removeEventListener?: (event: string, listener: (event: any) => void) => void;
onopen?: ((event: any) => void) | null;
onclose?: ((event: any) => void) | null;
onerror?: ((event: any) => void) | null;
onmessage?: ((event: any) => void) | null;
};
on?: (event: string, listener: (...args: unknown[]) => void) => void;
off?: (event: string, listener: (...args: unknown[]) => void) => void;
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
addEventListener?: (event: string, listener: (event: unknown) => void) => void;
removeEventListener?: (event: string, listener: (event: unknown) => void) => void;
onopen?: ((event: unknown) => void) | null;
onclose?: ((event: unknown) => void) | null;
onerror?: ((event: unknown) => void) | null;
onmessage?: ((event: unknown) => void) | null;
}
export type NodeWebSocketFactory = (
url: string,

View File

@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
export type PaseoHomeMetadataForkResult = {
export interface PaseoHomeMetadataForkResult {
sourceHome: string;
targetHome: string;
agentFiles: number;
@@ -13,13 +13,13 @@ export type PaseoHomeMetadataForkResult = {
copiedFiles: number;
copiedBytes: number;
skippedMissing: string[];
};
}
type CopyStats = {
interface CopyStats {
files: number;
bytes: number;
skippedMissing: string[];
};
}
export function resolvePaseoHomePath(value: string): string {
if (value === "~") {

View File

@@ -6,11 +6,11 @@ const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
const REGISTRY_KEY = "@paseo:daemon-registry";
const E2E_KEY = "@paseo:e2e";
type SavedHostInput = {
interface SavedHostInput {
serverId: string;
label: string;
endpoint: string;
};
}
export function startupScenario(page: Page) {
return new StartupScenario(page);
@@ -106,18 +106,15 @@ class StartupScenario {
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
await this.page.evaluate(
({ keys, registry, createAgentPreferences }) => {
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
const nonce = localStorage.getItem(keys.seedNonce);
if (!nonce) {
throw new Error("Expected e2e seed nonce before overriding startup registry.");
}
localStorage.setItem(keys.e2e, "1");
localStorage.setItem(keys.registry, JSON.stringify(registry));
localStorage.setItem(
"@paseo:create-agent-preferences",
JSON.stringify(createAgentPreferences),
);
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
},
{

View File

@@ -7,16 +7,16 @@ import {
type TerminalPerfDaemonClient,
} from "./terminal-perf";
type TempRepo = {
interface TempRepo {
path: string;
cleanup: () => Promise<void>;
};
}
export type TerminalInstance = {
export interface TerminalInstance {
id: string;
name: string;
cwd: string;
};
}
export class TerminalE2EHarness {
readonly client: TerminalPerfDaemonClient;

View File

@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
export interface TerminalPerfDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -37,7 +37,7 @@ export type TerminalPerfDaemonClient = {
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
): () => void;
killTerminal(terminalId: string): Promise<{ error: string | null }>;
};
}
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
@@ -55,12 +55,12 @@ function getServerId(): string {
return serverId;
}
type TerminalPerfDaemonClientConfig = {
interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
};
}
async function loadDaemonClientConstructor(): Promise<
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
@@ -100,7 +100,19 @@ function buildWorkspaceUrl(workspaceId: string): string {
export async function getTerminalBufferText(page: Page): Promise<string> {
return page.evaluate(() => {
const term = (window as any).__paseoTerminal;
const term = (
window as Window & {
__paseoTerminal?: {
buffer: {
active: {
length: number;
getLine: (i: number) => { translateToString: (trim: boolean) => string } | null;
};
};
onWriteParsed: (cb: () => void) => { dispose: () => void };
};
}
).__paseoTerminal;
if (!term) {
return "";
}
@@ -191,10 +203,10 @@ export async function setupDeterministicPrompt(page: Page, sentinel?: string): P
await page.waitForTimeout(300);
}
export type LatencySample = {
export interface LatencySample {
char: string;
latencyMs: number;
};
}
/**
* Measures keystroke echo round-trip latency.
@@ -205,12 +217,16 @@ export type LatencySample = {
*/
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
await page.evaluate(() => {
const term = (window as any).__paseoTerminal;
if (!term) {
const win = window as Window & {
__paseoTerminal?: { onWriteParsed: (cb: () => void) => { dispose: () => void } };
__perfKeystroke?: { promise: Promise<number> | null };
};
if (!win.__paseoTerminal) {
throw new Error("__paseoTerminal not available");
}
const term = win.__paseoTerminal;
const state = ((window as any).__perfKeystroke = {
const state = (win.__perfKeystroke = {
promise: null as Promise<number> | null,
});
@@ -236,7 +252,11 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
await page.keyboard.press(char);
return page.evaluate(() => (window as any).__perfKeystroke.promise);
return page.evaluate(
() =>
(window as unknown as { __perfKeystroke: { promise: Promise<number> } }).__perfKeystroke
.promise,
);
}
export function computePercentile(samples: number[], p: number): number {

View File

@@ -1,6 +1,6 @@
import type { Page } from "@playwright/test";
export type TerminalRenderProbeSnapshot = {
export interface TerminalRenderProbeSnapshot {
setCount: number;
unsetCount: number;
writeCount: number;
@@ -10,28 +10,28 @@ export type TerminalRenderProbeSnapshot = {
altExitWrites: number;
events: TerminalRenderProbeEvent[];
frames: TerminalFrame[];
};
}
export type TerminalRenderProbeEvent = {
export interface TerminalRenderProbeEvent {
at: number;
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
preview?: string;
};
}
export type TerminalFrame = {
export interface TerminalFrame {
at: number;
rowCount: number;
nonEmptyRows: number;
firstNonEmptyRow: number | null;
text: string;
topText: string;
};
}
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
frameCount: number;
};
export type TerminalKeystrokeStressReport = {
export interface TerminalKeystrokeStressReport {
inputTextLength: number;
keydownCount: number;
inputFrameCount: number;
@@ -70,21 +70,25 @@ export type TerminalKeystrokeStressReport = {
keydownToXtermCommitMs: LatencyStats | null;
firstKeydownAt: number | null;
lastXtermCommitAt: number | null;
};
}
export type LatencyStats = {
export interface LatencyStats {
count: number;
minMs: number;
p50Ms: number;
p95Ms: number;
maxMs: number;
avgMs: number;
};
}
export async function installTerminalRenderProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
type ProbeState = {
term: any;
interface ProbeTerm {
write?: (data: string | Uint8Array, callback?: () => void) => void;
__paseoRenderProbeWriteWrapped?: boolean;
}
interface ProbeState {
term: ProbeTerm | undefined;
setCount: number;
unsetCount: number;
writeCount: number;
@@ -99,9 +103,12 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
reset: () => void;
snapshot: () => TerminalRenderProbeSnapshot;
startSampling: (durationMs: number) => void;
};
}
const win = window as any;
const win = window as unknown as Record<string, unknown> & {
__terminalRenderProbe?: ProbeState;
__paseoTerminal?: ProbeTerm;
};
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
const getExisting = () =>
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
@@ -182,7 +189,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
get() {
return probe.term;
},
set(next: any) {
set(next: ProbeTerm | undefined) {
if (next === undefined) {
probe.unsetCount += 1;
probe.events.push({ at: performance.now(), type: "unset" });
@@ -201,7 +208,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
probe.writeCount += 1;
const preview = text
.replace(/\u001b/g, "\\x1b")
.replaceAll("\u001b", "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.slice(0, 160);
@@ -230,20 +237,30 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
});
}
interface TerminalRenderProbeWindow {
__terminalRenderProbe: {
reset: () => void;
startSampling: (durationMs: number) => void;
snapshot: () => TerminalRenderProbeSnapshot;
};
}
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(window as any).__terminalRenderProbe.reset();
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.reset();
});
}
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
await page.evaluate((ms) => {
(window as any).__terminalRenderProbe.startSampling(ms);
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.startSampling(ms);
}, durationMs);
}
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
return page.evaluate(() => (window as any).__terminalRenderProbe.snapshot());
return page.evaluate(() =>
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.snapshot(),
);
}
export async function terminalVisibleText(page: Page): Promise<string> {
@@ -272,29 +289,29 @@ export function summarizeTerminalRenderProbe(
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.addInitScript(() => {
type TimedTextEvent = {
interface TimedTextEvent {
at: number;
text: string;
bytes: number;
};
type TimedTextMessageEvent = {
}
interface TimedTextMessageEvent {
at: number;
bytes: number;
kind: string | null;
};
type XtermWriteEvent = {
}
interface XtermWriteEvent {
at: number;
committedAt: number | null;
text: string;
bytes: number;
};
type AppProbeEvent = {
}
interface AppProbeEvent {
type: string;
at: number;
bytes?: number;
queueDepth?: number;
};
type StressProbeState = {
}
interface StressProbeState {
keydowns: Array<{ at: number; key: string }>;
inputFrames: TimedTextEvent[];
outputFrames: TimedTextEvent[];
@@ -303,7 +320,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
appEvents: AppProbeEvent[];
reset: () => void;
report: (inputText: string) => TerminalKeystrokeStressReport;
};
}
const INPUT_OPCODE = 0x02;
const OUTPUT_OPCODE = 0x01;
@@ -553,8 +570,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
lastXtermCommitAt:
this.xtermWrites
.map((write) => write.committedAt)
.filter((at): at is number => typeof at === "number")
.at(-1) ?? null,
.findLast((at): at is number => typeof at === "number") ?? null,
};
},
};
@@ -592,6 +608,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
text: frameText(bytes),
bytes: bytes.byteLength - 2,
});
return;
});
void eventDataText(event.data).then((text) => {
if (text === null) {
@@ -602,11 +619,12 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
bytes: new TextEncoder().encode(text).byteLength,
kind: textMessageKind(text),
});
return;
});
});
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
send(data: Parameters<WebSocket["send"]>[0]): void {
const bytes = bytesFrom(data);
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
probe.inputFrames.push({
@@ -637,7 +655,10 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
get() {
return terminal;
},
set(next: any) {
set(next: {
write?: (data: string | Uint8Array, callback?: () => void) => void;
__paseoKeystrokeProbeWriteWrapped?: boolean;
}) {
terminal = next;
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
const originalWrite = next.write.bind(next);
@@ -663,9 +684,18 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
});
}
interface TerminalKeystrokeStressProbeWindow {
__terminalKeystrokeStressProbe: {
reset: () => void;
report: (text: string) => TerminalKeystrokeStressReport;
};
}
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
await page.evaluate(() => {
(window as any).__terminalKeystrokeStressProbe.reset();
(
window as unknown as TerminalKeystrokeStressProbeWindow
).__terminalKeystrokeStressProbe.reset();
});
}
@@ -674,7 +704,10 @@ export async function readTerminalKeystrokeStressReport(
inputText: string,
): Promise<TerminalKeystrokeStressReport> {
return page.evaluate(
(text) => (window as any).__terminalKeystrokeStressProbe.report(text),
(text) =>
(
window as unknown as TerminalKeystrokeStressProbeWindow
).__terminalKeystrokeStressProbe.report(text),
inputText,
);
}

View File

@@ -8,7 +8,7 @@ import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = {
interface WorkspaceSetupDaemonClient {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
@@ -52,7 +52,7 @@ type WorkspaceSetupDaemonClient = {
error?: string | null;
}>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
};
}
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
@@ -116,7 +116,7 @@ export async function seedProjectForWorkspaceSetup(
}
export function projectNameFromPath(repoPath: string): string {
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
return repoPath.replace(/\/+$/, "").split("/").findLast(Boolean) ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {

View File

@@ -3,11 +3,11 @@ import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
interface TempRepo {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
};
}
export const createTempGitRepo = async (
prefix = "paseo-e2e-",

View File

@@ -7,13 +7,13 @@ import { waitForSidebarHydration } from "./helpers/workspace-ui";
type WireDirection = "sent" | "received";
type WirePhase = "startup" | "workspace_clicks";
type ParsedWireMessage = {
interface ParsedWireMessage {
type: string | null;
requestId: string | null;
entryCount: number | null;
hasMore: boolean | null;
providerEntries: ProviderSnapshotWireEntry[] | null;
};
}
type WireFrameRecord = ParsedWireMessage & {
phase: WirePhase;
@@ -21,23 +21,23 @@ type WireFrameRecord = ParsedWireMessage & {
bytes: number;
};
type ProviderSnapshotWireEntry = {
interface ProviderSnapshotWireEntry {
provider: string;
status: string | null;
modelCount: number;
modeCount: number;
bytes: number;
};
}
type WebSocketFrameEvent = {
interface WebSocketFrameEvent {
requestId: string;
response: {
opcode: number;
payloadData: string;
};
};
}
type WireSummary = {
interface WireSummary {
totalFrames: number;
totalBytes: number;
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
@@ -84,7 +84,18 @@ type WireSummary = {
copiedFiles: number | null;
copiedBytes: number | null;
};
};
}
function extractWorkspaceTestIds(elements: Element[]): string[] {
const result: string[] = [];
for (const element of elements.slice(0, 3)) {
const value = element.getAttribute("data-testid");
if (value) {
result.push(value);
}
}
return result;
}
class WireMonitor {
private phase: WirePhase = "startup";
@@ -499,12 +510,7 @@ test.describe("ad hoc startup wire metrics", () => {
const workspaceTestIds = await page
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
.evaluateAll((elements) =>
elements
.slice(0, 3)
.map((element) => element.getAttribute("data-testid"))
.filter((value): value is string => Boolean(value)),
);
.evaluateAll(extractWorkspaceTestIds);
monitor.setPhase("workspace_clicks");
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];

View File

@@ -79,7 +79,10 @@ test.describe("Terminal alternate-screen transitions", () => {
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
{ delay: 0 },
);
await waitForTerminalContent(page, (text) => text.includes(historyReady), 10_000);
function hasHistoryReady(text: string): boolean {
return text.includes(historyReady);
}
await waitForTerminalContent(page, hasHistoryReady, 10_000);
await resetTerminalRenderProbe(page);
await page.waitForTimeout(500);
@@ -118,14 +121,16 @@ test.describe("Terminal alternate-screen transitions", () => {
expect(finalBufferText).toContain(historyReady);
expect(finalBufferText).toContain(afterAlt);
const suspiciousFrames = probe.frames.filter(
(frame) =>
function isSuspiciousFrame(frame: (typeof probe.frames)[number]): boolean {
return (
frame.text.includes("$") &&
!frame.text.includes(historyReady) &&
!frame.text.includes(afterAlt) &&
frame.nonEmptyRows <= 2 &&
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1,
);
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1
);
}
const suspiciousFrames = probe.frames.filter(isSuspiciousFrame);
expect(
suspiciousFrames,

View File

@@ -17,7 +17,7 @@ const STRESS_TIMEOUT_MS = 15_000;
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
type DaemonEchoReport = {
interface DaemonEchoReport {
inputTextLength: number;
inputFrameCount: number;
outputEventCount: number;
@@ -25,7 +25,7 @@ type DaemonEchoReport = {
sendToOutputMs: LatencyStats;
firstSendAt: number;
lastOutputAt: number;
};
}
terminalPerfDescribe("Terminal keystroke stress", () => {
let harness: TerminalE2EHarness;

View File

@@ -140,12 +140,12 @@ test.describe("Workspace navigation regression", () => {
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
await page.evaluate(
({ agentId, serverId }) => {
({ agentId, serverId: targetServerId }) => {
globalThis.dispatchEvent(
new CustomEvent("paseo:web-notification-click", {
detail: {
data: {
serverId,
serverId: targetServerId,
agentId,
reason: "finished",
},

View File

@@ -19,7 +19,7 @@ function getServerId(): string {
return serverId;
}
type WorkspaceScriptStarter = {
interface WorkspaceScriptStarter {
startWorkspaceScript(
workspaceId: string,
scriptName: string,
@@ -29,7 +29,7 @@ type WorkspaceScriptStarter = {
terminalId: string | null;
error: string | null;
}>;
};
}
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
@@ -353,10 +353,11 @@ test.describe("Workspace setup streaming", () => {
error: null,
});
const findEditorTerminal = (terminal: { name: string }) => terminal.name === "editor";
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspaceDir);
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
return terminals.terminals.find(findEditorTerminal) ?? null;
})
.toMatchObject({
id: expect.any(String),

1
packages/app/global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -17,7 +17,7 @@
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test",

View File

@@ -98,26 +98,10 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
polyfillCrypto();
export type HostRuntimeBootstrapState = {
export interface HostRuntimeBootstrapState {
phase: "starting-daemon" | "connecting" | "online" | "error";
error: string | null;
retry: () => void;
};
function getRouteParamValue(value: string | string[] | undefined): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (Array.isArray(value)) {
const firstValue = value[0];
if (typeof firstValue !== "string") {
return undefined;
}
const trimmed = firstValue.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
return undefined;
}
const HostRuntimeBootstrapContext = createContext<HostRuntimeBootstrapState>({
@@ -191,6 +175,7 @@ function PushNotificationRouter() {
return;
}
removeDesktopNotificationListener = unlisten;
return;
});
}
@@ -240,6 +225,7 @@ function PushNotificationRouter() {
if (response) {
openFromResponse(response);
}
return;
});
return () => {
@@ -678,9 +664,10 @@ function OfferLinkListener({
void upsertDaemonFromOfferUrl(url)
.then((profile) => {
if (cancelled) return;
const serverId = (profile as any)?.serverId;
const serverId = (profile as { serverId?: unknown } | null)?.serverId;
if (typeof serverId !== "string" || !serverId) return;
router.replace(buildHostRootRoute(serverId));
return;
})
.catch((error) => {
if (cancelled) return;
@@ -752,6 +739,7 @@ function OpenProjectListener() {
if (!disposed && pending) {
maybeOpenProject(pending);
}
return;
})
.catch(() => undefined);
@@ -769,6 +757,7 @@ function OpenProjectListener() {
return;
}
unlisten = dispose;
return;
})
.catch(() => undefined);
@@ -834,19 +823,23 @@ function FaviconStatusSync() {
return null;
}
const AGENT_SCREEN_OPTIONS = { gestureEnabled: false };
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
const stackScreenOptions = useMemo(
() => ({
headerShown: false,
animation: "none" as const,
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}),
[theme.colors.surface0],
);
return (
<Stack
screenOptions={{
headerShown: false,
animation: "none",
contentStyle: {
backgroundColor: theme.colors.surface0,
},
}}
>
<Stack screenOptions={stackScreenOptions}>
<Stack.Screen name="index" />
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
@@ -862,7 +855,7 @@ function RootStack() {
outside this route-level native-stack API.
*/}
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={{ gestureEnabled: false }} />
<Stack.Screen name="h/[serverId]/agent/[agentId]" options={AGENT_SCREEN_OPTIONS} />
<Stack.Screen name="h/[serverId]/index" />
<Stack.Screen name="h/[serverId]/sessions" />
<Stack.Screen name="h/[serverId]/open-project" />
@@ -894,37 +887,59 @@ function NavigationActiveWorkspaceObserver() {
return null;
}
function AppShell() {
return (
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
);
}
function RuntimeProviders({ children }: { children: ReactNode }) {
return (
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<SidebarCalloutProvider>
<ToastProvider>
<ProvidersWrapper>{children}</ProvidersWrapper>
</ToastProvider>
</SidebarCalloutProvider>
</HostRuntimeBootstrapProvider>
);
}
function RootProviders({ children }: { children: ReactNode }) {
return (
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>{children}</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
);
}
export default function RootLayout() {
const { theme } = useUnistyles();
const gestureRootStyle = useMemo(
() => ({ flex: 1, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
);
return (
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.surface0 }}>
<GestureHandlerRootView style={gestureRootStyle}>
<NavigationActiveWorkspaceObserver />
<PortalProvider>
<SafeAreaProvider>
<KeyboardProvider>
<QueryProvider>
<HostRuntimeBootstrapProvider>
<PushNotificationRouter />
<SidebarCalloutProvider>
<ToastProvider>
<ProvidersWrapper>
<SidebarAnimationProvider>
<HorizontalScrollProvider>
<OpenProjectListener />
<AppWithSidebar>
<RootStack />
</AppWithSidebar>
</HorizontalScrollProvider>
</SidebarAnimationProvider>
</ProvidersWrapper>
</ToastProvider>
</SidebarCalloutProvider>
</HostRuntimeBootstrapProvider>
</QueryProvider>
</KeyboardProvider>
</SafeAreaProvider>
</PortalProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</GestureHandlerRootView>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
@@ -44,7 +44,7 @@ function HostAgentReadyRouteContent() {
}
if (!serverId || !agentId) {
redirectedRef.current = true;
router.replace("/" as any);
router.replace("/" as Href);
return;
}
@@ -55,7 +55,7 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as any,
}) as Href,
);
}
}, [agentId, resolvedWorkspaceId, router, serverId]);
@@ -107,11 +107,12 @@ function HostAgentReadyRouteContent() {
serverId,
workspaceId,
target: { kind: "agent", agentId },
}) as any,
}) as Href,
);
return;
}
router.replace(buildHostRootRoute(serverId));
return;
})
.catch(() => {
if (cancelled || redirectedRef.current) {

View File

@@ -65,8 +65,8 @@ function stripOpenSearchParamFromBrowserUrl() {
}
function clearConsumedOpenIntent(input: {
navigation: { setParams: (...args: any[]) => void };
router: { replace: (...args: any[]) => void };
navigation: { setParams: (params: { open?: string | undefined }) => void };
router: ReturnType<typeof useRouter>;
serverId: string;
workspaceId: string;
}) {
@@ -137,7 +137,9 @@ function HostWorkspaceLayoutContent() {
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation,
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
router,
serverId,
workspaceId,
@@ -161,7 +163,9 @@ function HostWorkspaceLayoutContent() {
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
clearConsumedOpenIntent({
navigation,
navigation: navigation as unknown as {
setParams: (params: { open?: string | undefined }) => void;
},
router,
serverId,
workspaceId,

View File

@@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
@@ -147,7 +147,7 @@ export default function PairScanScreen() {
try {
router.back();
} catch {
router.replace("/" as any);
router.replace("/" as Href);
}
}, [router]);
@@ -198,15 +198,29 @@ export default function PairScanScreen() {
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
[insets.bottom, theme.spacing],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={() => router.back()} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<BackHeader title="Scan QR" onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
<Text style={styles.permissionBody}>
QR scanning isn't supported in the web build. Use "Paste link" instead.
{`QR scanning isn't supported in the web build. Use "Paste link" instead.`}
</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>Back to Settings</Text>
@@ -223,14 +237,14 @@ export default function PairScanScreen() {
<View style={styles.container}>
<BackHeader title="Scan QR" onBack={closeToSource} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Camera permission</Text>
<Text style={styles.permissionBody}>
Allow camera access to scan the pairing QR code from your daemon.
</Text>
<Pressable style={styles.permissionButton} onPress={() => void requestPermission()}>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>Grant permission</Text>
</Pressable>
</View>
@@ -239,21 +253,17 @@ export default function PairScanScreen() {
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
barcodeScannerSettings={BARCODE_SCANNER_SETTINGS}
onBarcodeScanned={handleScan}
/>
<View style={styles.overlay} pointerEvents="none">
<View style={styles.scanFrame}>
<View style={[styles.corner, styles.cornerTL]} />
<View style={[styles.corner, styles.cornerTR]} />
<View style={[styles.corner, styles.cornerBL]} />
<View style={[styles.corner, styles.cornerBR]} />
<View style={CORNER_TL_STYLE} />
<View style={CORNER_TR_STYLE} />
<View style={CORNER_BL_STYLE} />
<View style={CORNER_BR_STYLE} />
</View>
{isPairing ? (
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
Pairing
</Text>
) : null}
{isPairing ? <Text style={helperTextStyle}>Pairing</Text> : null}
</View>
</View>
)}
@@ -261,3 +271,9 @@ export default function PairScanScreen() {
</View>
);
}
const BARCODE_SCANNER_SETTINGS: BarcodeSettings = { barcodeTypes: ["qr"] };
const CORNER_TL_STYLE = [styles.corner, styles.cornerTL];
const CORNER_TR_STYLE = [styles.corner, styles.cornerTR];
const CORNER_BL_STYLE = [styles.corner, styles.cornerBL];
const CORNER_BR_STYLE = [styles.corner, styles.cornerBR];

View File

@@ -1,4 +1,5 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
@@ -6,6 +7,7 @@ export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
return <SettingsScreen view={{ kind: "section", section }} />;
return <SettingsScreen view={view} />;
}

View File

@@ -1,14 +1,16 @@
import { useLocalSearchParams } from "expo-router";
import { useMemo } from "react";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsHostRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const view = useMemo(() => ({ kind: "host" as const, serverId }), [serverId]);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={{ kind: "host", serverId }} />
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}

View File

@@ -3,6 +3,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import SettingsScreen from "@/screens/settings-screen";
import { buildSettingsSectionRoute } from "@/utils/host-routes";
const ROOT_VIEW = { kind: "root" as const };
export default function SettingsIndexRoute() {
const isCompactLayout = useIsCompactFormFactor();
@@ -10,5 +12,5 @@ export default function SettingsIndexRoute() {
return <Redirect href={buildSettingsSectionRoute("general")} />;
}
return <SettingsScreen view={{ kind: "root" }} />;
return <SettingsScreen view={ROOT_VIEW} />;
}

View File

@@ -114,12 +114,14 @@ export function createLocalFileAttachmentStore(params: {
await ensureDirectory(baseDirectory);
const id = input.id ?? generateAttachmentId();
const mimeTypeFromSource =
input.source.kind === "data_url"
? parseDataUrl(input.source.dataUrl).mimeType
: input.source.kind === "blob"
? input.source.blob.type
: undefined;
let mimeTypeFromSource: string | undefined;
if (input.source.kind === "data_url") {
mimeTypeFromSource = parseDataUrl(input.source.dataUrl).mimeType;
} else if (input.source.kind === "blob") {
mimeTypeFromSource = input.source.blob.type;
} else {
mimeTypeFromSource = undefined;
}
const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource);
const fileName = input.fileName ?? null;
const extension = extensionForAttachment({ fileName, mimeType });

View File

@@ -7,29 +7,37 @@ export function useAttachmentPreviewUrl(
): string | null {
const [url, setUrl] = useState<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
const attachmentRef = useRef(attachment);
attachmentRef.current = attachment;
const id = attachment?.id;
const storageType = attachment?.storageType;
const storageKey = attachment?.storageKey;
const mimeType = attachment?.mimeType;
useEffect(() => {
let disposed = false;
let currentUrl: string | null = null;
const current = attachmentRef.current;
activeAttachmentRef.current = attachment ?? null;
if (!attachment) {
activeAttachmentRef.current = current ?? null;
if (!current) {
setUrl(null);
return;
}
void (async () => {
try {
const resolved = await resolveAttachmentPreviewUrl(attachment);
const resolved = await resolveAttachmentPreviewUrl(current);
if (disposed) {
await releaseAttachmentPreviewUrl({ attachment, url: resolved });
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
return;
}
currentUrl = resolved;
setUrl(resolved);
} catch (error) {
console.error("[attachments] Failed to resolve preview URL", {
attachmentId: attachment.id,
attachmentId: current.id,
error,
});
if (!disposed) {
@@ -49,7 +57,7 @@ export function useAttachmentPreviewUrl(
url: currentUrl,
});
};
}, [attachment?.id, attachment?.storageType, attachment?.storageKey, attachment?.mimeType]);
}, [id, storageType, storageKey, mimeType]);
return url;
}

View File

@@ -98,7 +98,7 @@ export function createPreviewAttachmentId(input: {
export async function blobToBase64(blob: Blob): Promise<string> {
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
reader.addEventListener("load", () => {
if (typeof reader.result !== "string") {
reject(new Error("Unexpected FileReader result while encoding attachment."));
return;
@@ -109,10 +109,10 @@ export async function blobToBase64(blob: Blob): Promise<string> {
return;
}
resolve(payload);
};
reader.onerror = () => {
});
reader.addEventListener("error", () => {
reject(reader.error ?? new Error("Failed to read attachment blob."));
};
});
reader.readAsDataURL(blob);
});
}

View File

@@ -10,12 +10,12 @@ import {
parseDataUrl,
} from "@/attachments/utils";
type StoredBlobRecord = {
interface StoredBlobRecord {
id: string;
blob: Blob;
createdAt: number;
fileName: string | null;
};
}
const DB_NAME = "paseo-attachment-bytes";
const STORE_NAME = "attachments";
@@ -40,13 +40,13 @@ function openAttachmentDb(): Promise<IDBDatabase> {
}
};
request.onsuccess = () => {
request.addEventListener("success", () => {
resolve(request.result);
};
});
request.onerror = () => {
request.addEventListener("error", () => {
reject(request.error ?? new Error("Failed to open attachment IndexedDB."));
};
});
});
}
@@ -60,17 +60,17 @@ function runTx<T>(
const store = transaction.objectStore(STORE_NAME);
const request = run(store);
request.onsuccess = () => {
request.addEventListener("success", () => {
resolve(request.result as T);
};
});
request.onerror = () => {
request.addEventListener("error", () => {
reject(request.error ?? new Error("IndexedDB transaction request failed."));
};
});
transaction.onerror = () => {
transaction.addEventListener("error", () => {
reject(transaction.error ?? new Error("IndexedDB transaction failed."));
};
});
});
}
@@ -203,13 +203,13 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
const store = tx.objectStore(STORE_NAME);
const cursorRequest = store.openCursor();
cursorRequest.onerror = () => {
cursorRequest.addEventListener("error", () => {
reject(
cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store."),
);
};
});
cursorRequest.onsuccess = () => {
cursorRequest.addEventListener("success", () => {
const cursor = cursorRequest.result;
if (!cursor) {
resolve();
@@ -221,11 +221,11 @@ export function createIndexedDbAttachmentStore(): AttachmentStore {
cursor.delete();
}
cursor.continue();
};
});
tx.onerror = () => {
tx.addEventListener("error", () => {
reject(tx.error ?? new Error("Failed to garbage collect IndexedDB attachments."));
};
});
});
} finally {
db.close();

View File

@@ -1,5 +1,5 @@
import { forwardRef, useCallback, useEffect, useMemo } from "react";
import type { ReactNode } from "react";
import type { ReactNode, Ref } from "react";
import { createPortal } from "react-dom";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import type { TextInputProps } from "react-native";
@@ -24,6 +24,7 @@ import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
const ABSOLUTE_FILL_STYLE = { ...StyleSheet.absoluteFillObject };
function handleEscKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
@@ -143,18 +144,18 @@ const styles = StyleSheet.create((theme) => ({
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
return (
<View
style={[
style,
{
backgroundColor: theme.colors.surface1,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
},
]}
/>
const combinedStyle = useMemo(
() => [
style,
{
backgroundColor: theme.colors.surface1,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
},
],
[style, theme.colors.surface1, theme.borderRadius.xl],
);
return <View style={combinedStyle} />;
}
export interface AdaptiveModalSheetProps {
@@ -191,6 +192,10 @@ export function AdaptiveModalSheet({
const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const { sheetRef, handleSheetChange } = useIsolatedBottomSheetVisibility({
visible,
isEnabled: isMobile,
@@ -204,6 +209,12 @@ export function AdaptiveModalSheet({
[],
);
const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
);
useEffect(() => {
if (!isWeb || isMobile || !visible) return;
return pushEscHandler(onClose);
@@ -220,14 +231,14 @@ export function AdaptiveModalSheet({
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={SheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
handleIndicatorStyle={handleIndicatorStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
@@ -256,7 +267,7 @@ export function AdaptiveModalSheet({
<>
<View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={[styles.title, { color: titleColor }]} numberOfLines={1}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
@@ -283,12 +294,8 @@ export function AdaptiveModalSheet({
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
accessibilityLabel="Dismiss"
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
<Pressable accessibilityLabel="Dismiss" style={ABSOLUTE_FILL_STYLE} onPress={onClose} />
<View style={desktopCardStyle}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
) : (
@@ -326,7 +333,7 @@ export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
const isMobile = useIsCompactFormFactor();
if (isMobile) {
return <BottomSheetTextInput ref={ref as any} {...props} />;
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}
return <TextInput ref={ref} {...props} />;

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -10,6 +10,8 @@ import { DaemonConnectionTestError, connectToDaemon } from "@/utils/test-daemon-
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const styles = StyleSheet.create((theme) => ({
field: {
gap: theme.spacing[2],
@@ -158,6 +160,11 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
hostInputRef.current?.clear();
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
);
const handleClose = useCallback(() => {
if (isSaving) return;
clearInput();
@@ -214,13 +221,15 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
onSaved?.({ profile, serverId, hostname, isNewHost });
handleClose();
} catch (error) {
const { title, detail, raw } = buildConnectionFailureCopy(endpoint, error);
const combined =
raw && detail && raw !== detail
? `${title}\n${detail}\nDetails: ${raw}`
: detail
? `${title}\n${detail}`
: title;
const { title, detail, raw: rawDetail } = buildConnectionFailureCopy(endpoint, error);
let combined: string;
if (rawDetail && detail && rawDetail !== detail) {
combined = `${title}\n${detail}\nDetails: ${rawDetail}`;
} else if (detail) {
combined = `${title}\n${detail}`;
} else {
combined = title;
}
setErrorMessage(combined);
if (!isMobile) {
// Desktop/web: also surface it as a dialog for quick visibility.
@@ -231,6 +240,18 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
const handleChangeEndpoint = useCallback((next: string) => {
endpointRawRef.current = next;
}, []);
const handleSubmitEditing = useCallback(() => {
void handleSave();
}, [handleSave]);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
return (
<AdaptiveModalSheet
title="Direct connection"
@@ -247,9 +268,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={(next) => {
endpointRawRef.current = next;
}}
onChangeText={handleChangeEndpoint}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -258,21 +277,26 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
keyboardType="url"
editable={!isSaving}
returnKeyType="done"
onSubmitEditing={() => void handleSave()}
onSubmitEditing={handleSubmitEditing}
/>
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
</View>
<View style={styles.actions}>
<Button style={{ flex: 1 }} variant="secondary" onPress={handleCancel} disabled={isSaving}>
<Button
style={FLEX_ONE_STYLE}
variant="secondary"
onPress={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button
style={{ flex: 1 }}
style={FLEX_ONE_STYLE}
variant="default"
onPress={() => void handleSave()}
onPress={handleSavePress}
disabled={isSaving}
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
leftIcon={connectIcon}
testID="direct-host-submit"
>
{isSaving ? "Connecting..." : "Connect"}

View File

@@ -6,10 +6,11 @@ import {
RefreshControl,
FlatList,
type ListRenderItem,
type PressableStateCallbackType,
} 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 { router, type Href } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
@@ -149,24 +150,26 @@ function SessionBadge({
icon?: ReactElement;
tone?: "neutral" | "warning" | "danger";
}) {
const badgeStyle = useMemo(
() => [
styles.badge,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
],
[tone],
);
const badgeTextStyle = useMemo(
() => [
styles.badgeText,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
],
[tone],
);
return (
<View
style={[
styles.badge,
tone === "warning" && styles.badgeWarning,
tone === "danger" && styles.badgeDanger,
]}
>
<View style={badgeStyle}>
{icon}
<Text
style={[
styles.badgeText,
tone === "warning" && styles.badgeTextWarning,
tone === "danger" && styles.badgeTextDanger,
]}
>
{label}
</Text>
<Text style={badgeTextStyle}>{label}</Text>
</View>
);
}
@@ -194,16 +197,34 @@ function SessionRow({
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
isSelected && styles.rowSelected,
Boolean(hovered) && styles.rowHovered,
pressed && styles.rowPressed,
],
[isSelected],
);
const handlePress = useCallback(() => onPress(agent), [onPress, agent]);
const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]);
const sessionTitleStyle = useMemo(
() => [styles.sessionTitle, isSelected && styles.sessionTitleHighlighted],
[isSelected],
);
const archivedIcon = useMemo(
() => <Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />,
[theme.fontSize.xs, theme.colors.foregroundMuted],
);
return (
<Pressable
style={({ pressed, hovered }) => [
styles.row,
isSelected && styles.rowSelected,
hovered && styles.rowHovered,
pressed && styles.rowPressed,
]}
onPress={() => onPress(agent)}
onLongPress={() => onLongPress(agent)}
style={pressableStyle}
onPress={handlePress}
onLongPress={handleLongPress}
testID={`agent-row-${agent.serverId}-${agent.id}`}
>
<View style={styles.rowContent}>
@@ -211,18 +232,10 @@ function SessionRow({
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
>
<Text style={sessionTitleStyle} numberOfLines={1}>
{agent.title || "New session"}
</Text>
{agent.archivedAt ? (
<SessionBadge
label="Archived"
icon={<Archive size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
/>
) : null}
{agent.archivedAt ? <SessionBadge label="Archived" icon={archivedIcon} /> : null}
{(agent.pendingPermissionCount ?? 0) > 0 ? (
<SessionBadge label={`${agent.pendingPermissionCount} pending`} tone="warning" />
) : null}
@@ -306,7 +319,7 @@ export function AgentList({
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as Href);
return;
}
@@ -402,6 +415,32 @@ export function AgentList({
const keyExtractor = useCallback((item: FlatListItem) => item.key, []);
const refreshColors = useMemo(
() => [theme.colors.foregroundMuted],
[theme.colors.foregroundMuted],
);
const sheetContainerStyle = useMemo(
() => [styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }],
[insets.bottom, theme.spacing],
);
const sheetArchiveTextStyle = useMemo(
() => [styles.sheetArchiveText, isActionDaemonUnavailable && styles.sheetArchiveTextDisabled],
[isActionDaemonUnavailable],
);
const refreshControl = useMemo(
() =>
onRefresh ? (
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={refreshColors}
/>
) : undefined,
[onRefresh, isRefreshing, theme.colors.foregroundMuted, refreshColors],
);
return (
<>
<FlatList
@@ -413,16 +452,7 @@ export function AgentList({
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
ListFooterComponent={listFooterComponent}
refreshControl={
onRefresh ? (
<RefreshControl
refreshing={isRefreshing}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={[theme.colors.foregroundMuted]}
/>
) : undefined
}
refreshControl={refreshControl}
/>
<Modal
@@ -433,12 +463,7 @@ export function AgentList({
>
<View style={styles.sheetOverlay}>
<Pressable style={styles.sheetBackdrop} onPress={handleCloseActionSheet} />
<View
style={[
styles.sheetContainer,
{ paddingBottom: Math.max(insets.bottom, theme.spacing[6]) },
]}
>
<View style={sheetContainerStyle}>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable
@@ -447,7 +472,7 @@ export function AgentList({
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
style={[styles.sheetButton, styles.sheetCancelButton]}
style={SHEET_CANCEL_BUTTON_STYLE}
onPress={handleCloseActionSheet}
testID="agent-action-cancel"
>
@@ -455,18 +480,11 @@ export function AgentList({
</Pressable>
<Pressable
disabled={isActionDaemonUnavailable}
style={[styles.sheetButton, styles.sheetArchiveButton]}
style={SHEET_ARCHIVE_BUTTON_STYLE}
onPress={handleArchiveAgent}
testID="agent-action-archive"
>
<Text
style={[
styles.sheetArchiveText,
isActionDaemonUnavailable && styles.sheetArchiveTextDisabled,
]}
>
Archive
</Text>
<Text style={sheetArchiveTextStyle}>Archive</Text>
</Pressable>
</View>
</View>
@@ -677,3 +695,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
},
}));
const SHEET_CANCEL_BUTTON_STYLE = [styles.sheetButton, styles.sheetCancelButton];
const SHEET_ARCHIVE_BUTTON_STYLE = [styles.sheetButton, styles.sheetArchiveButton];

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,72 @@ export function getFeatureHighlightColor(featureId: string): FeatureHighlightCol
}
}
function findModelById(
models: AgentModelDefinition[] | null,
modelId: string | null,
): AgentModelDefinition | null {
if (!models || !modelId) {
return null;
}
return models.find((model) => model.id === modelId) ?? null;
}
function getFallbackModel(models: AgentModelDefinition[] | null): AgentModelDefinition | null {
return models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
}
function resolvePreferredModelId(
runtimeSelectedModel: AgentModelDefinition | null,
normalizedConfiguredModelId: string | null,
normalizedRuntimeModelId: string | null,
): string | null {
return runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
}
function pickSelectedModel(
models: AgentModelDefinition[] | null,
preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null,
): AgentModelDefinition | null {
if (!models || !preferredModelId) {
return fallbackModel;
}
return findModelById(models, preferredModelId) ?? fallbackModel;
}
function resolveThinkingId(
explicitThinkingOptionId: string | null | undefined,
selectedModel: AgentModelDefinition | null,
): string | null {
if (explicitThinkingOptionId && explicitThinkingOptionId !== "default") {
return explicitThinkingOptionId;
}
return selectedModel?.defaultThinkingOptionId ?? null;
}
type ThinkingOption = NonNullable<AgentModelDefinition["thinkingOptions"]>[number];
function resolveEffectiveThinking(
thinkingOptions: ThinkingOption[] | null,
resolvedThinkingId: string | null,
): ThinkingOption | null {
const selectedThinking =
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
return selectedThinking ?? thinkingOptions?.[0] ?? null;
}
function resolveModelDisplay(
selectedModel: AgentModelDefinition | null,
preferredModelId: string | null,
fallbackModel: AgentModelDefinition | null,
): { activeModelId: string | null; displayModel: string } {
return {
activeModelId: selectedModel?.id ?? preferredModelId ?? null,
displayModel:
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model",
};
}
export function resolveAgentModelSelection(input: {
models: AgentModelDefinition[] | null;
runtimeModelId: string | null | undefined;
@@ -46,30 +112,25 @@ export function resolveAgentModelSelection(input: {
const { models, runtimeModelId, configuredModelId, explicitThinkingOptionId } = input;
const normalizedRuntimeModelId = normalizeModelId(runtimeModelId);
const normalizedConfiguredModelId = normalizeModelId(configuredModelId);
const runtimeSelectedModel =
models && normalizedRuntimeModelId
? (models.find((model) => model.id === normalizedRuntimeModelId) ?? null)
: null;
const preferredModelId =
runtimeSelectedModel?.id ?? normalizedConfiguredModelId ?? normalizedRuntimeModelId;
const fallbackModel = models?.find((model) => model.isDefault) ?? models?.[0] ?? null;
const selectedModel =
models && preferredModelId
? (models.find((model) => model.id === preferredModelId) ?? fallbackModel ?? null)
: fallbackModel;
const activeModelId = selectedModel?.id ?? preferredModelId ?? null;
const displayModel =
selectedModel?.label ?? preferredModelId ?? fallbackModel?.label ?? "Unknown model";
const runtimeSelectedModel = findModelById(models, normalizedRuntimeModelId);
const preferredModelId = resolvePreferredModelId(
runtimeSelectedModel,
normalizedConfiguredModelId,
normalizedRuntimeModelId,
);
const fallbackModel = getFallbackModel(models);
const selectedModel = pickSelectedModel(models, preferredModelId, fallbackModel);
const { activeModelId, displayModel } = resolveModelDisplay(
selectedModel,
preferredModelId,
fallbackModel,
);
const thinkingOptions = selectedModel?.thinkingOptions ?? null;
const resolvedThinkingId =
explicitThinkingOptionId && explicitThinkingOptionId !== "default"
? explicitThinkingOptionId
: (selectedModel?.defaultThinkingOptionId ?? null);
const selectedThinking =
thinkingOptions?.find((option) => option.id === resolvedThinkingId) ?? null;
const effectiveThinking = selectedThinking ?? thinkingOptions?.[0] ?? null;
const resolvedThinkingId = resolveThinkingId(explicitThinkingOptionId, selectedModel);
const effectiveThinking = resolveEffectiveThinking(thinkingOptions, resolvedThinkingId);
const selectedThinkingId = effectiveThinking?.id ?? null;
const displayThinking = effectiveThinking?.label ?? selectedThinkingId ?? "Unknown";

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -41,7 +42,12 @@ export function AgentStatusDot({
return null;
}
return <View style={[styles.dot, { backgroundColor: color }]} />;
return <AgentStatusDotView color={color} />;
}
function AgentStatusDotView({ color }: { color: string }) {
const dotStyle = useMemo(() => [styles.dot, { backgroundColor: color }], [color]);
return <View style={dotStyle} />;
}
function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {

View File

@@ -11,37 +11,37 @@ import {
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
export type StreamRenderSegments = {
export interface StreamRenderSegments {
historyVirtualized: StreamItem[];
historyMounted: StreamItem[];
liveHead: StreamItem[];
};
}
export type StreamHistoryBoundary = {
export interface StreamHistoryBoundary {
hasVirtualizedHistory: boolean;
hasMountedHistory: boolean;
hasLiveHead: boolean;
historyToHeadGap: number;
};
}
export type StreamRenderAuxiliary = {
export interface StreamRenderAuxiliary {
pendingPermissions: ReactNode;
workingIndicator: ReactNode;
};
}
export type AgentStreamRenderModel = {
export interface AgentStreamRenderModel {
history: StreamItem[];
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
auxiliary: StreamRenderAuxiliary;
};
}
export type BuildAgentStreamRenderModelInput = {
export interface BuildAgentStreamRenderModelInput {
tail: StreamItem[];
head: StreamItem[];
platform: "web" | "native";
isMobileBreakpoint: boolean;
};
}
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {

View File

@@ -7,8 +7,17 @@ import {
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from "react";
import { View, Text, Pressable, Platform, ActivityIndicator } from "react-native";
import {
View,
Text,
Pressable,
Platform,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
@@ -249,8 +258,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
},
[
agent.cwd,
agent.projectPlacement?.checkout?.isGit,
isMobile,
openFileExplorerForCheckout,
onOpenWorkspaceFile,
requestDirectoryListing,
resolvedServerId,
router,
@@ -281,9 +292,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[],
);
function scrollToBottom() {
const scrollToBottom = useCallback(() => {
viewportRef.current?.scrollToBottom("jump-to-bottom");
}
}, []);
const tightGap = theme.spacing[1]; // 4px
const assistantBlockGap = theme.spacing[3]; // 12px
@@ -318,6 +329,182 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[assistantBlockGap, looseGap, tightGap],
);
const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
return;
}
setExpandedInlineToolCallIds((previous) => {
const next = new Set(previous);
if (expanded) {
next.add(itemId);
} else {
next.delete(itemId);
}
return next;
});
},
[streamRenderStrategy],
);
const renderUserMessageItem = useCallback(
(
item: Extract<StreamItem, { kind: "user_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
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 (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
/>
);
},
[streamRenderStrategy],
);
const renderAssistantMessageItem = useCallback(
(
item: Extract<StreamItem, { kind: "assistant_message" }>,
index: number,
items: StreamItem[],
seamAboveItem: StreamItem | null,
) => {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
spacing={spacing}
/>
);
},
[handleInlinePathPress, streamRenderStrategy, workspaceRoot, serverId, client],
);
const renderThoughtItem = useCallback(
(item: Extract<StreamItem, { kind: "thought" }>, index: number, items: StreamItem[]) => {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
/>
);
},
[streamRenderStrategy, setInlineDetailsExpanded],
);
const renderToolCallItem = useCallback(
(item: Extract<StreamItem, { kind: "tool_call" }>, index: number, items: StreamItem[]) => {
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;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return (
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
);
}
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.name}
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
/>
);
}
const data = payload.data;
return (
<ToolCallSlot
itemId={item.id}
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
/>
);
},
[agent.cwd, streamRenderStrategy, setInlineDetailsExpanded],
);
const renderStreamItemContent = useCallback(
(
item: StreamItem,
@@ -325,153 +512,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
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: "below",
});
const isFirstInGroup = aboveItem?.kind !== "user_message";
const isLastInGroup = belowItem?.kind !== "user_message";
return (
<UserMessage
message={item.text}
images={item.images}
timestamp={item.timestamp.getTime()}
isFirstInGroup={isFirstInGroup}
isLastInGroup={isLastInGroup}
/>
);
}
case "user_message":
return renderUserMessageItem(item, index, items, seamAboveItem);
case "assistant_message": {
const aboveItem =
getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "above",
}) ??
seamAboveItem ??
undefined;
const belowItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const spacing = getAssistantBlockSpacing({
item,
aboveItem,
belowItem,
});
return (
<AssistantMessage
message={item.text}
timestamp={item.timestamp.getTime()}
onInlinePathPress={handleInlinePathPress}
workspaceRoot={workspaceRoot}
serverId={serverId}
client={client}
spacing={spacing}
/>
);
}
case "thought": {
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
return (
<ToolCall
toolName="thinking"
args={item.text}
status={item.status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
case "assistant_message":
return renderAssistantMessageItem(item, index, items, seamAboveItem);
case "tool_call": {
const { payload } = item;
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const isLastInSequence = nextItem?.kind !== "tool_call" && nextItem?.kind !== "thought";
case "thought":
return renderThoughtItem(item, index, items);
if (payload.source === "agent") {
const data = payload.data;
if (
data.name === "speak" &&
data.detail.type === "unknown" &&
typeof data.detail.input === "string" &&
data.detail.input.trim()
) {
return (
<SpeakMessage message={data.detail.input} timestamp={item.timestamp.getTime()} />
);
}
return (
<ToolCall
toolName={data.name}
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
metadata={data.metadata}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
const data = payload.data;
return (
<ToolCall
toolName={data.toolName}
args={data.arguments}
result={data.result}
status={data.status}
isLastInSequence={isLastInSequence}
onInlineDetailsExpandedChange={handleInlineDetailsExpandedChange}
/>
);
}
case "tool_call":
return renderToolCallItem(item, index, items);
case "activity_log":
return (
@@ -493,7 +545,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null;
}
},
[handleInlinePathPress, agent.cwd, streamRenderStrategy],
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
);
const renderStreamItem = useCallback(
@@ -519,18 +571,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const getTurnContent = () =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy: streamRenderStrategy,
items,
startIndex: index,
});
return (
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
<StreamItemWrapper gapBelow={gapBelow}>
{content}
{isEndOfAssistantTurn ? <TurnCopyButton getContent={getTurnContent} /> : null}
</View>
{isEndOfAssistantTurn ? (
<TurnCopyButtonSlot
strategy={streamRenderStrategy}
items={items}
startIndex={index}
/>
) : null}
</StreamItemWrapper>
);
},
[getGapBetween, renderStreamItemContent, agent.status, streamRenderStrategy],
@@ -579,6 +631,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
};
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(() => {
if (
renderModel.boundary.hasVirtualizedHistory ||
@@ -591,14 +644,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<View style={emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}, [renderModel]);
}, [renderModel, emptyStateStyle]);
const historyItems = renderModel.history;
const liveHeadItems = renderModel.segments.liveHead;
const _liveHeadItems = renderModel.segments.liveHead;
const { boundary, auxiliary } = renderModel;
const lastHistoryItem = historyItems.at(-1) ?? null;
@@ -633,34 +686,27 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
[lastHistoryItem, renderStreamItem],
);
const liveAuxiliaryHeaderStyle = useMemo(() => {
let headerPadding: { paddingBottom: number } | { paddingTop: number } | null;
if (!boundary.hasLiveHead) headerPadding = null;
else if (streamRenderStrategy.getFlatListInverted())
headerPadding = { paddingBottom: looseGap };
else headerPadding = { paddingTop: looseGap };
return [stylesheet.listHeaderContent, headerPadding];
}, [boundary.hasLiveHead, streamRenderStrategy, looseGap]);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
return null;
}
return (
<View style={stylesheet.contentWrapper}>
<View
style={[
stylesheet.listHeaderContent,
boundary.hasLiveHead
? streamRenderStrategy.getFlatListInverted()
? { paddingBottom: looseGap }
: { paddingTop: looseGap }
: null,
]}
>
<View style={liveAuxiliaryHeaderStyle}>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
}, [
auxiliary.pendingPermissions,
auxiliary.workingIndicator,
boundary.hasLiveHead,
looseGap,
streamRenderStrategy,
]);
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
@@ -774,18 +820,123 @@ function WorkingIndicator() {
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
return (
<View style={stylesheet.workingIndicatorBubble}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={[stylesheet.workingDot, dotOneStyle]} />
<Animated.View style={[stylesheet.workingDot, dotTwoStyle]} />
<Animated.View style={[stylesheet.workingDot, dotThreeStyle]} />
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
>[0]["strategy"];
interface TurnCopyButtonSlotProps {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
return <TurnCopyButton getContent={getContent} />;
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
> {
itemId: string;
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
}
function ToolCallSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
...rest
}: ToolCallSlotProps) {
const handleExpandedChange = useCallback(
(expanded: boolean) => onInlineDetailsExpandedChangeByItemId(itemId, expanded),
[onInlineDetailsExpandedChangeByItemId, itemId],
);
return <ToolCall {...rest} onInlineDetailsExpandedChange={handleExpandedChange} />;
}
interface PermissionActionButtonProps {
action: AgentPermissionAction;
isRespondingAction: boolean;
isResponding: boolean;
textColor: string;
iconColor: string;
isDanger: boolean;
Icon: typeof Check;
testID: string;
theme: ReturnType<typeof useUnistyles>["theme"];
onPress: (action: AgentPermissionAction) => void;
}
function PermissionActionButton({
action,
isRespondingAction,
isResponding,
textColor,
iconColor,
isDanger,
Icon,
testID,
theme,
onPress,
}: PermissionActionButtonProps) {
const handlePress = useCallback(() => onPress(action), [onPress, action]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent, isDanger],
);
const optionTextStyle = useMemo(
() => [permissionStyles.optionText, { color: textColor }],
[textColor],
);
return (
<Pressable testID={testID} style={pressableStyle} onPress={handlePress} disabled={isResponding}>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={optionTextStyle}>{action.label}</Text>
</View>
)}
</Pressable>
);
}
function PermissionRequestCard({
permission,
client,
@@ -800,6 +951,15 @@ function PermissionRequestCard({
const isPlanRequest = request.kind === "plan";
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
const description = request.description ?? "";
const resolvedToolCallDetail = useMemo(
() =>
request.detail ?? {
type: "unknown" as const,
input: request.input ?? null,
output: null,
},
[request.detail, request.input],
);
const resolvedActions = useMemo((): AgentPermissionAction[] => {
if (request.kind === "question") {
return [];
@@ -900,6 +1060,36 @@ function PermissionRequestCard({
[handleResponse],
);
const questionTextStyle = useMemo(
() => [permissionStyles.question, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const optionsContainerStyle = useMemo(
() => [
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
],
[isMobile],
);
const cardContainerStyle = useMemo(
() => [
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[theme.colors.surface1, theme.colors.border],
);
const cardTitleStyle = useMemo(
() => [permissionStyles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cardDescriptionStyle = useMemo(
() => [permissionStyles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
if (request.kind === "question") {
return (
<QuestionFormCard
@@ -912,19 +1102,11 @@ function PermissionRequestCard({
const footer = (
<>
<Text
testID="permission-request-question"
style={[permissionStyles.question, { color: theme.colors.foregroundMuted }]}
>
<Text testID="permission-request-question" style={questionTextStyle}>
How would you like to proceed?
</Text>
<View
style={[
permissionStyles.optionsContainer,
!isMobile && permissionStyles.optionsContainerDesktop,
]}
>
<View style={optionsContainerStyle}>
{resolvedActions.map((action) => {
const isDanger = action.variant === "danger" || action.behavior === "deny";
const isPrimary = action.variant === "primary";
@@ -932,39 +1114,26 @@ function PermissionRequestCard({
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = textColor;
const Icon = action.behavior === "allow" ? Check : X;
const testID =
action.behavior === "deny"
? "permission-request-deny"
: action.id === "accept" || action.id === "implement"
? "permission-request-accept"
: `permission-request-action-${action.id}`;
let testID: string;
if (action.behavior === "deny") testID = "permission-request-deny";
else if (action.id === "accept" || action.id === "implement")
testID = "permission-request-accept";
else testID = `permission-request-action-${action.id}`;
return (
<Pressable
<PermissionActionButton
key={action.id}
action={action}
isRespondingAction={isRespondingAction}
isResponding={isResponding}
textColor={textColor}
iconColor={iconColor}
isDanger={isDanger}
Icon={Icon}
testID={testID}
style={({ pressed, hovered = false }) => [
permissionStyles.optionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
},
pressed ? permissionStyles.optionButtonPressed : null,
]}
onPress={() => handleActionPress(action)}
disabled={isResponding}
>
{isRespondingAction ? (
<ActivityIndicator size="small" color={textColor} />
) : (
<View style={permissionStyles.optionContent}>
<Icon size={14} color={iconColor} />
<Text style={[permissionStyles.optionText, { color: textColor }]}>
{action.label}
</Text>
</View>
)}
</Pressable>
theme={theme}
onPress={handleActionPress}
/>
);
})}
</View>
@@ -984,38 +1153,17 @@ function PermissionRequestCard({
}
return (
<View
style={[
permissionStyles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[permissionStyles.title, { color: theme.colors.foreground }]}>{title}</Text>
<View style={cardContainerStyle}>
<Text style={cardTitleStyle}>{title}</Text>
{description ? (
<Text style={[permissionStyles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
{description ? <Text style={cardDescriptionStyle}>{description}</Text> : null}
{planMarkdown ? (
<PlanCard title="Proposed plan" text={planMarkdown} disableOuterSpacing />
) : null}
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
<ToolCallDetailsContent detail={resolvedToolCallDetail} maxHeight={200} />
) : null}
{footer}
@@ -1203,3 +1351,16 @@ const permissionStyles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal,
},
}));
interface StreamItemWrapperProps {
gapBelow: number;
children: ReactNode;
}
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
const wrapperStyle = useMemo(
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
[gapBelow],
);
return <View style={wrapperStyle}>{children}</View>;
}

View File

@@ -31,15 +31,15 @@ export function getWebMountedRecentStreamItems(): number {
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
}
export type IndexedStreamItem = {
export interface IndexedStreamItem {
item: StreamItem;
index: number;
};
}
export type WebVirtualizedHistoryWindow = {
export interface WebVirtualizedHistoryWindow {
virtualizedEntries: IndexedStreamItem[];
mountedEntries: IndexedStreamItem[];
};
}
export function estimateStreamItemHeight(item: StreamItem): number {
switch (item.kind) {

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import Animated from "react-native-reanimated";
@@ -22,7 +22,12 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
async function handleUnarchive() {
const containerStyle = useMemo(
() => [styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle],
[insets.bottom, keyboardAnimatedStyle],
);
const handleUnarchive = useCallback(async () => {
if (!client || !isConnected || isUnarchiving) return;
setIsUnarchiving(true);
try {
@@ -31,12 +36,10 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
setIsUnarchiving(false);
}
}
}, [client, isConnected, isUnarchiving, agentId]);
return (
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<Animated.View style={containerStyle}>
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
<View style={styles.callout}>
@@ -56,7 +59,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
const styles = StyleSheet.create((theme: Theme) => ({
container: {
flexDirection: "column",
position: "relative",
@@ -96,4 +99,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
})) as any) as Record<string, any>;
})) as unknown as Record<string, object>;

View File

@@ -7,7 +7,7 @@ import type { AttachmentMetadata } from "@/attachments/types";
import { AttachmentLightbox } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
const theme = {
const hoistedTheme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
@@ -24,7 +24,7 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
},
};
const imageMetadata: AttachmentMetadata = {
const hoistedImageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
@@ -35,8 +35,8 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
};
return {
theme,
imageMetadata,
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -36,6 +36,21 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
};
}, [metadata, onClose]);
const closeButtonStyle = useMemo(
() => [
styles.closeButton,
{
top: insets.top + theme.spacing[3],
right: insets.right + theme.spacing[3],
},
],
[insets.top, insets.right, theme.spacing],
);
const handleImageError = useCallback(() => setErrored(true), []);
const noopPress = useCallback(() => {}, []);
const imageSource = useMemo(() => ({ uri: url ?? "" }), [url]);
if (!metadata) {
return null;
}
@@ -55,14 +70,14 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>Couldn't load image</Text>
<Text style={styles.errorText}>Couldn&apos;t load image</Text>
) : (
<Pressable onPress={() => {}} style={styles.imagePressable}>
<Pressable onPress={noopPress} style={styles.imagePressable}>
<ExpoImage
testID="attachment-lightbox-image"
source={{ uri: url }}
source={imageSource}
contentFit="contain"
onError={() => setErrored(true)}
onError={handleImageError}
style={imageFillStyle}
/>
</Pressable>
@@ -74,13 +89,7 @@ export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProp
accessibilityLabel="Close image"
hitSlop={8}
onPress={onClose}
style={[
styles.closeButton,
{
top: insets.top + theme.spacing[3],
right: insets.right + theme.spacing[3],
},
]}
style={closeButtonStyle}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useState } from "react";
import { type ReactNode, useCallback, useMemo, useState } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
@@ -30,14 +30,22 @@ export function AttachmentPill({
const [isCloseHovered, setIsCloseHovered] = useState(false);
const alwaysShow = isNative || isCompact;
const showRemove = alwaysShow || isBodyHovered || isCloseHovered;
const closeButtonStyle = useMemo(
() => [styles.closeButton, !showRemove && styles.closeButtonHidden],
[showRemove],
);
const handleBodyHoverIn = useCallback(() => setIsBodyHovered(true), []);
const handleBodyHoverOut = useCallback(() => setIsBodyHovered(false), []);
const handleCloseHoverIn = useCallback(() => setIsCloseHovered(true), []);
const handleCloseHoverOut = useCallback(() => setIsCloseHovered(false), []);
return (
<View style={styles.wrapper}>
<Pressable
testID={testID}
onPress={onOpen}
disabled={disabled}
onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)}
onHoverIn={handleBodyHoverIn}
onHoverOut={handleBodyHoverOut}
accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel}
style={styles.body}
@@ -47,12 +55,12 @@ export function AttachmentPill({
<Pressable
onPress={onRemove}
disabled={disabled}
onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)}
onHoverIn={handleCloseHoverIn}
onHoverOut={handleCloseHoverOut}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={removeAccessibilityLabel}
style={[styles.closeButton, !showRemove && styles.closeButtonHidden]}
style={closeButtonStyle}
>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>

View File

@@ -1,9 +1,10 @@
import { useRef } from "react";
import { Pressable, View } from "react-native";
import { useCallback, useMemo, useRef } from "react";
import { Pressable, View, type PressableStateCallbackType } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxProps } from "@/components/ui/combobox";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
@@ -51,6 +52,34 @@ export function BranchSwitcher({
</View>
);
const handleOpen = useCallback(() => setIsOpen(true), [setIsOpen]);
const triggerStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.branchSwitcherTrigger,
(Boolean(hovered) || pressed) && styles.branchSwitcherTriggerHovered,
],
[],
);
const branchLeadingSlot = useMemo(
() => <GitBranch size={14} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
const renderBranchOption = useCallback<NonNullable<ComboboxProps["renderOption"]>>(
({ option, selected, active, onPress }) => (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={branchLeadingSlot}
/>
),
[branchLeadingSlot],
);
if (!currentBranchName) {
return <View style={styles.branchSwitcherTrigger}>{titleContent}</View>;
}
@@ -59,11 +88,8 @@ export function BranchSwitcher({
<View ref={anchorRef} collapsable={false}>
<Pressable
testID="workspace-header-branch-switcher"
onPress={() => setIsOpen(true)}
style={({ hovered, pressed }) => [
styles.branchSwitcherTrigger,
(hovered || pressed) && styles.branchSwitcherTriggerHovered,
]}
onPress={handleOpen}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Current branch: ${currentBranchName}. Press to switch branch.`}
>
@@ -85,16 +111,7 @@ export function BranchSwitcher({
desktopPlacement="bottom-start"
desktopPreventInitialFlash
desktopMinWidth={280}
renderOption={({ option, selected, active, onPress }) => (
<ComboboxItem
key={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={<GitBranch size={14} color={theme.colors.foregroundMuted} />}
/>
)}
renderOption={renderBranchOption}
/>
</View>
);

View File

@@ -57,6 +57,21 @@ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { CalloutCard } from "./callout-card";
type CalloutCardActions = React.ComponentProps<typeof CalloutCard>["actions"];
function buildSingleAction(onPress: () => void): CalloutCardActions {
return [{ label: "Undo", onPress }];
}
function buildTwoActions(onWhatsNew: () => void, onInstall: () => void): CalloutCardActions {
return [
{ label: "What's new", onPress: onWhatsNew },
{ label: "Install & restart", onPress: onInstall, variant: "primary" },
];
}
const calloutTitleIcon = <span data-testid="callout-title-icon" />;
describe("CalloutCard", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
@@ -91,9 +106,7 @@ describe("CalloutCard", () => {
it("renders an icon next to the title", () => {
act(() => {
root?.render(
<CalloutCard title="Update available" icon={<span data-testid="callout-title-icon" />} />,
);
root?.render(<CalloutCard title="Update available" icon={calloutTitleIcon} />);
});
expect(container?.querySelector('[data-testid="callout-title-icon"]')).not.toBeNull();
@@ -101,14 +114,9 @@ describe("CalloutCard", () => {
it("renders one action when one is provided", () => {
const onPress = vi.fn();
const actions = buildSingleAction(onPress);
act(() => {
root?.render(
<CalloutCard
description="Saved."
actions={[{ label: "Undo", onPress }]}
testID="callout"
/>,
);
root?.render(<CalloutCard description="Saved." actions={actions} testID="callout" />);
});
const button = container?.querySelector(
@@ -119,15 +127,13 @@ describe("CalloutCard", () => {
});
it("renders up to two actions", () => {
const actions = buildTwoActions(vi.fn(), vi.fn());
act(() => {
root?.render(
<CalloutCard
title="Update available"
description="v1 ready."
actions={[
{ label: "What's new", onPress: vi.fn() },
{ label: "Install & restart", onPress: vi.fn(), variant: "primary" },
]}
actions={actions}
testID="callout"
/>,
);

View File

@@ -1,6 +1,6 @@
import { X } from "lucide-react-native";
import type { ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export type CalloutActionVariant = "primary" | "secondary";
@@ -43,7 +43,10 @@ export function CalloutCard({
const hasHeader = title != null || icon != null;
const hasDescription = description != null && description !== "";
const containerStyle = [styles.container, variant === "error" ? styles.containerError : null];
const containerStyle = useMemo(
() => [styles.container, variant === "error" ? styles.containerError : null],
[variant],
);
return (
<View style={containerStyle} testID={testID} accessibilityRole="alert">
@@ -80,19 +83,18 @@ export function CalloutCard({
</View>
) : null}
{hasDescription ? (
typeof description === "string" ? (
<CalloutDescriptionText>{description}</CalloutDescriptionText>
) : (
<View style={styles.descriptionSlot}>{description}</View>
)
{hasDescription && typeof description === "string" ? (
<CalloutDescriptionText>{description}</CalloutDescriptionText>
) : null}
{hasDescription && typeof description !== "string" ? (
<View style={styles.descriptionSlot}>{description}</View>
) : null}
{visibleActions.length > 0 ? (
<View style={styles.actionRow} testID={testID ? `${testID}-actions` : undefined}>
{visibleActions.map((action, index) => (
<CalloutActionButton
key={`${action.label}-${index}`}
key={action.label}
action={action}
testID={action.testID ?? (testID ? `${testID}-action-${index}` : undefined)}
/>
@@ -106,26 +108,28 @@ export function CalloutCard({
function CalloutActionButton({ action, testID }: { action: CalloutAction; testID?: string }) {
const isPrimary = action.variant === "primary";
const labelStyle = useMemo(
() => [styles.actionLabel, isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary],
[isPrimary],
);
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.actionButton,
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
pressed ? styles.actionButtonPressed : null,
action.disabled ? styles.actionButtonDisabled : null,
],
[action.disabled, isPrimary],
);
return (
<Pressable
onPress={action.onPress}
disabled={action.disabled}
testID={testID}
accessibilityRole="button"
style={({ pressed }) => [
styles.actionButton,
isPrimary ? styles.actionButtonPrimary : styles.actionButtonSecondary,
pressed ? styles.actionButtonPressed : null,
action.disabled ? styles.actionButtonDisabled : null,
]}
style={pressableStyle}
>
<Text
style={[
styles.actionLabel,
isPrimary ? styles.actionLabelPrimary : styles.actionLabelSecondary,
]}
numberOfLines={1}
>
<Text style={labelStyle} numberOfLines={1}>
{action.label}
</Text>
</Pressable>

View File

@@ -1,3 +1,5 @@
import type { Theme } from "@/styles/theme";
/**
* Compute the pixel width for a line-number gutter based on the highest
* line number that will be displayed. Minimum width accommodates 2 digits.
@@ -7,13 +9,11 @@ export function lineNumberGutterWidth(maxLineNumber: number): number {
return digits * 8 + 12;
}
export function getCodeInsets(theme: any) {
const padding =
typeof theme.spacing?.[3] === "number"
? theme.spacing[3]
: typeof theme.spacing?.[4] === "number"
? theme.spacing[4]
: 12;
export function getCodeInsets(theme: Theme) {
let padding: number;
if (typeof theme.spacing?.[3] === "number") padding = theme.spacing[3];
else if (typeof theme.spacing?.[4] === "number") padding = theme.spacing[4];
else padding = 12;
const extraRight = theme.spacing[4];
const extraBottom = theme.spacing[3];

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Ref } from "react";
import {
View,
Text,
@@ -6,6 +7,7 @@ import {
Pressable,
ActivityIndicator,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -16,7 +18,41 @@ import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/a
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem } from "@/components/ui/combobox";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
const EMPTY_COMBOBOX_OPTIONS: ReadonlyArray<ComboboxOption> = [];
function noop() {}
function favoriteButtonStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.favoriteButton,
Boolean(hovered) && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
];
}
function drillDownRowStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.drillDownRow,
Boolean(hovered) && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
];
}
function backButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
return [
styles.backButton,
Boolean(hovered) && styles.backButtonHovered,
pressed && styles.backButtonPressed,
];
}
import { getProviderIcon } from "@/components/provider-icons";
import {
buildModelRows,
@@ -167,6 +203,49 @@ function ModelRow({
[onToggleFavorite, row.modelId, row.provider],
);
const leadingSlot = useMemo(
() => <ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />,
[ProviderIcon, theme.iconSize.sm, theme.colors.foregroundMuted],
);
const trailingSlot = useMemo(
() =>
onToggleFavorite && !disabled ? (
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
style={favoriteButtonStyle}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => {
let starColor: string;
if (isFavorite) starColor = theme.colors.palette.amber[500];
else if (hovered) starColor = theme.colors.foregroundMuted;
else starColor = theme.colors.border;
return (
<Star
size={16}
color={starColor}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
);
}}
</Pressable>
) : null,
[
onToggleFavorite,
disabled,
handleToggleFavorite,
isFavorite,
row.provider,
row.modelId,
theme.colors.palette.amber,
theme.colors.foregroundMuted,
theme.colors.border,
],
);
const showDescription = row.description && PROVIDERS_WITH_MODEL_DESCRIPTIONS.has(row.provider);
return (
@@ -177,37 +256,43 @@ function ModelRow({
disabled={disabled}
elevated={elevated}
onPress={onPress}
leadingSlot={<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />}
trailingSlot={
onToggleFavorite && !disabled ? (
<Pressable
onPress={handleToggleFavorite}
hitSlop={8}
style={({ pressed, hovered }) => [
styles.favoriteButton,
hovered && styles.favoriteButtonHovered,
pressed && styles.favoriteButtonPressed,
]}
accessibilityRole="button"
accessibilityLabel={isFavorite ? "Unfavorite model" : "Favorite model"}
testID={`favorite-model-${row.provider}-${row.modelId}`}
>
{({ hovered }) => (
<Star
size={16}
color={
isFavorite
? theme.colors.palette.amber[500]
: hovered
? theme.colors.foregroundMuted
: theme.colors.border
}
fill={isFavorite ? theme.colors.palette.amber[500] : "transparent"}
/>
)}
</Pressable>
) : null
}
leadingSlot={leadingSlot}
trailingSlot={trailingSlot}
/>
);
}
interface SelectableModelRowProps {
row: SelectorModelRow;
isSelected: boolean;
isFavorite: boolean;
disabled?: boolean;
elevated?: boolean;
onSelect: (provider: string, modelId: string) => void;
onToggleFavorite?: (provider: string, modelId: string) => void;
}
function SelectableModelRow({
row,
isSelected,
isFavorite,
disabled,
elevated,
onSelect,
onToggleFavorite,
}: SelectableModelRowProps) {
const handlePress = useCallback(() => {
onSelect(row.provider, row.modelId);
}, [onSelect, row.provider, row.modelId]);
return (
<ModelRow
row={row}
isSelected={isSelected}
isFavorite={isFavorite}
disabled={disabled}
elevated={elevated}
onPress={handlePress}
onToggleFavorite={onToggleFavorite}
/>
);
}
@@ -229,7 +314,7 @@ function FavoritesSection({
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
}) {
const { theme } = useUnistyles();
const { theme: _theme } = useUnistyles();
if (favoriteRows.length === 0) {
return null;
@@ -241,14 +326,14 @@ function FavoritesSection({
<Text style={styles.sectionHeadingText}>Favorites</Text>
</View>
{favoriteRows.map((row) => (
<ModelRow
<SelectableModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
elevated
onPress={() => onSelect(row.provider, row.modelId)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
))}
@@ -256,8 +341,39 @@ function FavoritesSection({
);
}
interface GroupProviderButtonProps {
providerId: string;
providerLabel: string;
rowCount: number;
onDrillDown: (providerId: string, providerLabel: string) => void;
}
function GroupProviderButton({
providerId,
providerLabel,
rowCount,
onDrillDown,
}: GroupProviderButtonProps) {
const { theme } = useUnistyles();
const ProvIcon = getProviderIcon(providerId);
const handlePress = useCallback(() => {
onDrillDown(providerId, providerLabel);
}, [onDrillDown, providerId, providerLabel]);
return (
<Pressable onPress={handlePress} style={drillDownRowStyle}>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{rowCount} {rowCount === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
);
}
function GroupedProviderRows({
providerDefinitions,
groupedRows,
selectedProvider,
selectedModel,
@@ -268,7 +384,6 @@ function GroupedProviderRows({
onDrillDown,
viewKind,
}: {
providerDefinitions: AgentProviderDefinition[];
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
selectedProvider: string;
selectedModel: string;
@@ -279,15 +394,9 @@ function GroupedProviderRows({
onDrillDown: (providerId: string, providerLabel: string) => void;
viewKind: SelectorView["kind"];
}) {
const { theme } = useUnistyles();
return (
<View>
{groupedRows.map((group, index) => {
const providerDefinition = providerDefinitions.find(
(definition) => definition.id === group.providerId,
);
const ProvIcon = getProviderIcon(group.providerId);
const isInline = viewKind === "provider";
return (
@@ -296,35 +405,24 @@ function GroupedProviderRows({
{isInline ? (
<>
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
<ModelRow
<SelectableModelRow
key={row.favoriteKey}
row={row}
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onPress={() => onSelect(row.provider, row.modelId)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<Pressable
onPress={() => onDrillDown(group.providerId, group.providerLabel)}
style={({ pressed, hovered }) => [
styles.drillDownRow,
hovered && styles.drillDownRowHovered,
pressed && styles.drillDownRowPressed,
]}
>
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.drillDownText}>{group.providerLabel}</Text>
<View style={styles.drillDownTrailing}>
<Text style={styles.drillDownCount}>
{group.rows.length} {group.rows.length === 1 ? "model" : "models"}
</Text>
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
</Pressable>
<GroupProviderButton
providerId={group.providerId}
providerLabel={group.providerLabel}
rowCount={group.rows.length}
onDrillDown={onDrillDown}
/>
)}
</View>
);
@@ -356,13 +454,18 @@ function ProviderSearchInput({
}
}, [autoFocus]);
const inputStyle = useMemo(
() => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }],
[],
);
return (
<View style={styles.providerSearchContainer}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<InputComponent
ref={inputRef as any}
ref={inputRef as unknown as Ref<never>}
// @ts-expect-error - outlineStyle is web-only
style={[styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }]}
style={inputStyle}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
@@ -381,7 +484,7 @@ function SelectorContent({
selectedProvider,
selectedModel,
searchQuery,
onSearchChange,
onSearchChange: _onSearchChange,
favoriteKeys,
onSelect,
canSelectProvider,
@@ -408,7 +511,7 @@ function SelectorContent({
[normalizedQuery, scopedRows],
);
const { favoriteRows, regularRows } = useMemo(
const { favoriteRows, regularRows: _regularRows } = useMemo(
() => partitionRows(visibleRows, favoriteKeys),
[favoriteKeys, visibleRows],
);
@@ -446,7 +549,6 @@ function SelectorContent({
{filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
providerDefinitions={providerDefinitions}
groupedRows={filteredGroupedRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
@@ -486,14 +588,7 @@ function ProviderBackButton({
}
return (
<Pressable
onPress={onBack}
style={({ pressed, hovered }) => [
styles.backButton,
hovered && styles.backButtonHovered,
pressed && styles.backButtonPressed,
]}
>
<Pressable onPress={onBack} style={backButtonStyle}>
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.backButtonText}>{providerLabel}</Text>
@@ -619,20 +714,59 @@ export function CombinedModelSelector({
return () => cancelAnimationFrame(frame);
}, [isOpen]);
const handleTriggerPress = useCallback(() => {
handleOpenChange(!isOpen);
}, [handleOpenChange, isOpen]);
const triggerStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
Boolean(hovered) && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
],
[disabled, isOpen, renderTrigger],
);
const handleBackToAll = useCallback(() => {
setView({ kind: "all" });
setSearchQuery("");
}, []);
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
setView({ kind: "provider", providerId, providerLabel });
}, []);
const stickyHeader = useMemo(
() =>
view.kind === "provider" ? (
<View style={styles.level2Header}>
{!singleProviderView ? (
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={handleBackToAll}
/>
) : null}
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={platformIsWeb}
/>
</View>
) : undefined,
[view, singleProviderView, handleBackToAll, searchQuery],
);
return (
<>
<Pressable
ref={anchorRef}
collapsable={false}
disabled={disabled}
onPress={() => handleOpenChange(!isOpen)}
style={({ pressed, hovered }) => [
styles.trigger,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
renderTrigger ? styles.customTriggerWrapper : null,
]}
onPress={handleTriggerPress}
style={triggerStyle}
accessibilityRole="button"
accessibilityLabel={`Select model (${selectedModelLabel})`}
testID="combined-model-selector"
@@ -640,7 +774,7 @@ export function CombinedModelSelector({
{renderTrigger ? (
renderTrigger({
selectedModelLabel: triggerLabel,
onPress: () => handleOpenChange(!isOpen),
onPress: handleTriggerPress,
disabled,
isOpen,
})
@@ -657,9 +791,9 @@ export function CombinedModelSelector({
)}
</Pressable>
<Combobox
options={[]}
options={EMPTY_COMBOBOX_OPTIONS as ComboboxOption[]}
value=""
onSelect={() => {}}
onSelect={noop}
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
@@ -667,27 +801,7 @@ export function CombinedModelSelector({
desktopMinWidth={360}
desktopFixedHeight={desktopFixedHeight}
title="Select model"
stickyHeader={
view.kind === "provider" ? (
<View style={styles.level2Header}>
{!singleProviderView ? (
<ProviderBackButton
providerId={view.providerId}
providerLabel={view.providerLabel}
onBack={() => {
setView({ kind: "all" });
setSearchQuery("");
}}
/>
) : null}
<ProviderSearchInput
value={searchQuery}
onChangeText={setSearchQuery}
autoFocus={platformIsWeb}
/>
</View>
) : undefined
}
stickyHeader={stickyHeader}
>
{isContentReady ? (
<SelectorContent
@@ -702,9 +816,7 @@ export function CombinedModelSelector({
onSelect={handleSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={(providerId, providerLabel) => {
setView({ kind: "provider", providerId, providerLabel });
}}
onDrillDown={handleDrillDown}
/>
) : (
<View style={styles.sheetLoadingState}>

View File

@@ -1,5 +1,13 @@
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import { memo, useEffect, useRef, type ReactNode } from "react";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { memo, useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
@@ -14,12 +22,12 @@ function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
}
type CommandCenterRowProps = {
interface CommandCenterRowProps {
active: boolean;
children: ReactNode;
onPress: () => void;
registerRow: (el: View | null) => void;
};
}
const CommandCenterRow = memo(function CommandCenterRow({
active,
@@ -29,22 +37,214 @@ const CommandCenterRow = memo(function CommandCenterRow({
}: CommandCenterRowProps) {
const { theme } = useUnistyles();
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
],
[active, theme.colors.surface1],
);
return (
<Pressable
ref={registerRow}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={onPress}
>
<Pressable ref={registerRow} style={pressableStyle} onPress={onPress}>
{children}
</Pressable>
);
});
interface CommandCenterRowContainerProps {
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onPress: () => void;
children: ReactNode;
}
function CommandCenterRowContainer({
rowIndex,
active,
rowRefs,
onPress,
children,
}: CommandCenterRowContainerProps) {
const registerRow = useCallback(
(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
},
[rowRefs, rowIndex],
);
return (
<CommandCenterRow active={active} registerRow={registerRow} onPress={onPress}>
{children}
</CommandCenterRow>
);
}
interface CommandCenterActionRowProps {
item: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "action" }>;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
}
function CommandCenterActionRow({
item,
rowIndex,
active,
rowRefs,
onSelect,
}: CommandCenterActionRowProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
const action = item.action;
let actionIcon: React.ReactNode = null;
if (action.icon === "plus") {
actionIcon = <Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />;
} else if (action.icon === "settings") {
actionIcon = <Settings size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />;
}
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
return (
<CommandCenterRowContainer
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onPress={handlePress}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? <View style={styles.iconSlot}>{actionIcon}</View> : null}
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{action.title}
</Text>
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRowContainer>
);
}
interface CommandCenterAgentRowProps {
item: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "agent" }>;
rowIndex: number;
active: boolean;
rowRefs: React.MutableRefObject<Map<number, View>>;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
children: ReactNode;
}
function CommandCenterAgentRow({
rowIndex,
active,
rowRefs,
onSelect,
item,
children,
}: CommandCenterAgentRowProps) {
const handlePress = useCallback(() => onSelect(item), [onSelect, item]);
return (
<CommandCenterRowContainer
rowIndex={rowIndex}
active={active}
rowRefs={rowRefs}
onPress={handlePress}
>
{children}
</CommandCenterRowContainer>
);
}
interface CommandCenterAgentRowContentProps {
agent: AggregatedAgent;
}
function CommandCenterAgentRowContent({ agent }: CommandCenterAgentRowContentProps) {
const { theme } = useUnistyles();
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const subtitleStyle = useMemo(
() => [styles.subtitle, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View style={styles.rowContent}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<Text style={titleStyle} numberOfLines={1}>
{agent.title || "New agent"}
</Text>
<Text style={subtitleStyle} numberOfLines={1}>
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</View>
</View>
);
}
interface AgentItemsSectionProps {
agentItems: Extract<ReturnType<typeof useCommandCenter>["items"][number], { kind: "agent" }>[];
actionItemsLength: number;
activeIndex: number;
rowRefs: React.MutableRefObject<Map<number, View>>;
onSelect: (item: ReturnType<typeof useCommandCenter>["items"][number]) => void;
sectionDividerStyle: React.ComponentProps<typeof View>["style"];
sectionLabelStyle: React.ComponentProps<typeof Text>["style"];
}
function AgentItemsSection({
agentItems,
actionItemsLength,
activeIndex,
rowRefs,
onSelect,
sectionDividerStyle,
sectionLabelStyle,
}: AgentItemsSectionProps) {
return (
<>
{actionItemsLength > 0 ? <View style={sectionDividerStyle} /> : null}
<Text style={sectionLabelStyle}>Agents</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItemsLength + index;
const agent = item.agent;
return (
<CommandCenterAgentRow
key={agentKey(agent)}
item={item}
rowIndex={rowIndex}
active={rowIndex === activeIndex}
rowRefs={rowRefs}
onSelect={onSelect}
>
<CommandCenterAgentRowContent agent={agent} />
</CommandCenterAgentRow>
);
})}
</>
);
}
export function CommandCenter() {
const { theme } = useUnistyles();
const { open, inputRef, query, setQuery, activeIndex, items, handleClose, handleSelectItem } =
@@ -91,24 +291,46 @@ export function CommandCenter() {
}
}, [activeIndex, open]);
if (isNative || !open) return null;
const actionItems = useMemo(() => items.filter((item) => item.kind === "action"), [items]);
const agentItems = useMemo(() => items.filter((item) => item.kind === "agent"), [items]);
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
const panelStyle = useMemo(
() => [
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
],
[theme.colors.border, theme.colors.surface0],
);
const headerStyle = useMemo(
() => [styles.header, { borderBottomColor: theme.colors.border }],
[theme.colors.border],
);
const inputStyle = useMemo(
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const emptyTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const sectionLabelStyle = useMemo(
() => [styles.sectionLabel, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const sectionDividerStyle = useMemo(
() => [styles.sectionDivider, { backgroundColor: theme.colors.border }],
[theme.colors.border],
);
if (isNative || !open) return null;
return (
<Modal visible={open} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View
testID="command-center-panel"
style={[
styles.panel,
{ borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 },
]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<View testID="command-center-panel" style={panelStyle}>
<View style={headerStyle}>
<TextInput
testID="command-center-input"
ref={inputRef}
@@ -116,7 +338,7 @@ export function CommandCenter() {
onChangeText={setQuery}
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[styles.input, { color: theme.colors.foreground }]}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@@ -131,116 +353,35 @@ export function CommandCenter() {
showsVerticalScrollIndicator={false}
>
{items.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
No matches
</Text>
<Text style={emptyTextStyle}>No matches</Text>
) : (
<>
{actionItems.length > 0 ? (
<>
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Actions
</Text>
{actionItems.map((item, index) => {
const active = index === activeIndex;
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus size={16} strokeWidth={2.4} color={theme.colors.foregroundMuted} />
) : action.icon === "settings" ? (
<Settings
size={16}
strokeWidth={2.2}
color={theme.colors.foregroundMuted}
/>
) : null;
return (
<CommandCenterRow
key={`action:${action.id}`}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(index, el);
else rowRefs.current.delete(index);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? (
<View style={styles.iconSlot}>{actionIcon}</View>
) : null}
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{action.title}
</Text>
</View>
</View>
{action.shortcutKeys ? (
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</CommandCenterRow>
);
})}
<Text style={sectionLabelStyle}>Actions</Text>
{actionItems.map((item, index) => (
<CommandCenterActionRow
key={`action:${item.action.id}`}
item={item}
rowIndex={index}
active={index === activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
/>
))}
</>
) : null}
{agentItems.length > 0 ? (
<>
{actionItems.length > 0 ? (
<View
style={[styles.sectionDivider, { backgroundColor: theme.colors.border }]}
/>
) : null}
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Agents
</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItems.length + index;
const active = rowIndex === activeIndex;
const agent = item.agent;
return (
<CommandCenterRow
key={agentKey(agent)}
registerRow={(el: View | null) => {
if (el) rowRefs.current.set(rowIndex, el);
else rowRefs.current.delete(rowIndex);
}}
active={active}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</View>
</View>
</CommandCenterRow>
);
})}
</>
<AgentItemsSection
agentItems={agentItems}
actionItemsLength={actionItems.length}
activeIndex={activeIndex}
rowRefs={rowRefs}
onSelect={handleSelectItem}
sectionDividerStyle={sectionDividerStyle}
sectionLabelStyle={sectionLabelStyle}
/>
) : null}
</>
)}
@@ -280,7 +421,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as any,
} as object,
results: {
flexGrow: 0,
},

View File

@@ -83,12 +83,17 @@ export function useComposerHeightMirror({
}
ms.width = `${source.clientWidth}px`;
const { value, minHeight, maxHeight, onHeight } = paramsRef.current;
const {
value: currentValue,
minHeight: currentMinHeight,
maxHeight: currentMaxHeight,
onHeight: currentOnHeight,
} = paramsRef.current;
// Trailing newline is collapsed by textarea measurement — pad with a space.
mirror.value = value.endsWith("\n") ? `${value} ` : value;
mirror.value = currentValue.endsWith("\n") ? `${currentValue} ` : currentValue;
const next = Math.max(minHeight, Math.min(maxHeight, mirror.scrollHeight));
onHeight(next);
const next = Math.max(currentMinHeight, Math.min(currentMaxHeight, mirror.scrollHeight));
currentOnHeight(next);
}, [textareaRef]);
useLayoutEffect(() => {

View File

@@ -29,7 +29,7 @@ const {
setQueuedMessagesMock,
agentDirectoryStatusMock,
} = vi.hoisted(() => {
const theme = {
const hoistedTheme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
@@ -59,7 +59,7 @@ const {
},
};
const imageMetadata: AttachmentMetadata = {
const hoistedImageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
@@ -69,7 +69,7 @@ const {
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
const hoistedIssueItem: GitHubSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
@@ -81,7 +81,7 @@ const {
headRefName: null,
};
const prItem: GitHubSearchItem = {
const hoistedPrItem: GitHubSearchItem = {
kind: "pr",
number: 202,
title: "Refactor composer attachments",
@@ -93,15 +93,15 @@ const {
headRefName: "composer-attachments",
};
const mockClient = {
const hoistedMockClient = {
isConnected: true,
searchGitHub: vi.fn(async () => ({ items: [issueItem, prItem] })),
searchGitHub: vi.fn(async () => ({ items: [hoistedIssueItem, hoistedPrItem] })),
sendAgentMessage: vi.fn(async () => {}),
cancelAgent: vi.fn(async () => {}),
};
const setQueuedMessagesMock = vi.fn();
const mockSessionState: {
const hoistedSetQueuedMessagesMock = vi.fn();
const hoistedMockSessionState: {
sessions: Record<
string,
{
@@ -145,42 +145,42 @@ const {
agentStreamTail: new Map(),
},
},
setQueuedMessages: setQueuedMessagesMock,
setQueuedMessages: hoistedSetQueuedMessagesMock,
};
const setAgentStreamTailMock = vi.fn(
const hoistedSetAgentStreamTailMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = mockSessionState.sessions[serverId];
const session = hoistedMockSessionState.sessions[serverId];
session.agentStreamTail = updater(session.agentStreamTail);
},
);
const setAgentStreamHeadMock = vi.fn(
const hoistedSetAgentStreamHeadMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = mockSessionState.sessions[serverId];
const session = hoistedMockSessionState.sessions[serverId];
session.agentStreamHead = updater(session.agentStreamHead);
},
);
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
const markScrollInvestigationRenderMock = vi.fn();
const agentDirectoryStatusMock = vi.fn(() => "ready");
hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock;
hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock;
const hoistedMarkScrollInvestigationRenderMock = vi.fn();
const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready");
return {
theme,
imageMetadata,
issueItem,
prItem,
mockClient,
theme: hoistedTheme,
imageMetadata: hoistedImageMetadata,
issueItem: hoistedIssueItem,
prItem: hoistedPrItem,
mockClient: hoistedMockClient,
pickImagesMock: vi.fn(),
persistAttachmentFromBlobMock: vi.fn(async () => imageMetadata),
persistAttachmentFromBlobMock: vi.fn(async () => hoistedImageMetadata),
deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}),
markScrollInvestigationRenderMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
agentDirectoryStatusMock,
markScrollInvestigationRenderMock: hoistedMarkScrollInvestigationRenderMock,
mockSessionState: hoistedMockSessionState,
setAgentStreamTailMock: hoistedSetAgentStreamTailMock,
setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock,
setQueuedMessagesMock: hoistedSetQueuedMessagesMock,
agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock,
};
});
@@ -408,7 +408,7 @@ vi.mock("@/components/ui/shortcut", () => ({
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => children,
TooltipTrigger: ({
asChild,
children,
@@ -423,13 +423,13 @@ vi.mock("@/components/ui/tooltip", () => ({
disabled?: boolean;
}) =>
asChild ? (
<>{children}</>
children
) : (
<button type="button" aria-label={accessibilityLabel} disabled={disabled} onClick={onPress}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/components/ui/dropdown-menu", () => {
@@ -441,9 +441,8 @@ vi.mock("@/components/ui/dropdown-menu", () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = React.useState(false);
return (
<DropdownContext.Provider value={{ open, setOpen }}>{children}</DropdownContext.Provider>
);
const contextValue = React.useMemo(() => ({ open, setOpen }), [open]);
return <DropdownContext.Provider value={contextValue}>{children}</DropdownContext.Provider>;
},
DropdownMenuTrigger: ({
children,
@@ -459,13 +458,14 @@ vi.mock("@/components/ui/dropdown-menu", () => {
disabled?: boolean;
}) => {
const menu = React.useContext(DropdownContext);
const handleClick = React.useCallback(() => menu?.setOpen(true), [menu]);
return (
<button
type="button"
data-testid={testID}
aria-label={accessibilityLabel}
disabled={disabled}
onClick={() => menu?.setOpen(true)}
onClick={handleClick}
>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: menu?.open ?? false })
@@ -654,6 +654,17 @@ function ComposerHarness({
const [attachments, setAttachments] = useState(initialAttachments);
latestAttachments = attachments;
const handleChangeAttachments = React.useCallback(
(updater: ComposerAttachment[] | ((current: ComposerAttachment[]) => ComposerAttachment[])) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
return next;
});
},
[],
);
return (
<QueryClientProvider client={queryClient!}>
<Composer
@@ -663,13 +674,7 @@ function ComposerHarness({
value={text}
onChangeText={setText}
attachments={attachments}
onChangeAttachments={(updater) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
return next;
});
}}
onChangeAttachments={handleChangeAttachments}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
cwd="/repo"

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@ import Svg, { Circle } from "react-native-svg";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type ContextWindowMeterProps = {
interface ContextWindowMeterProps {
maxTokens: number;
usedTokens: number;
};
}
const SVG_SIZE = 16;
const CENTER = SVG_SIZE / 2;

View File

@@ -8,12 +8,12 @@ import { useSessionStore } from "@/stores/session-store";
import { resolveAppVersion } from "@/utils/app-version";
import { buildSettingsHostRoute } from "@/utils/host-routes";
type DaemonVersionMismatch = {
interface DaemonVersionMismatch {
serverId: string;
label: string;
appVersion: string;
daemonVersion: string;
};
}
function useDaemonVersionMismatches(): DaemonVersionMismatch[] {
const hosts = useHosts();

View File

@@ -17,6 +17,26 @@ import { isNative } from "@/constants/platform";
* On macOS, Electron handles edge resize natively.
*/
const DRAG_OVERLAY_STYLE: React.CSSProperties = {
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
};
const TOP_RESIZER_STYLE: React.CSSProperties = {
position: "absolute",
top: 0,
width: "100%",
height: 4,
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "no-drag",
};
/**
* Static drag overlay and top-edge resizer. Returns null on non-Electron.
* Place as FIRST child of any positioned container that should be draggable.
@@ -29,29 +49,9 @@ export function TitlebarDragRegion() {
return (
<>
{/* Drag overlay — VS Code .titlebar-drag-region (titlebarpart.css:57-64) */}
<div
style={{
top: 0,
left: 0,
display: "block",
position: "absolute",
width: "100%",
height: "100%",
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "drag",
}}
/>
<div style={DRAG_OVERLAY_STYLE} />
{/* Top-edge resizer — VS Code .resizer (titlebarpart.css:249-256) */}
<div
style={{
position: "absolute",
top: 0,
width: "100%",
height: 4,
// @ts-expect-error — WebkitAppRegion is not in CSSProperties
WebkitAppRegion: "no-drag",
}}
/>
<div style={TOP_RESIZER_STYLE} />
</>
);
}

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
@@ -47,6 +48,23 @@ export function DictationControls({
const actionsDisabled = isProcessing;
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
const micButtonStyle = useMemo(
() => [styles.micButton, disabled && styles.buttonDisabled],
[disabled],
);
const timerTextStyle = useMemo(
() => [styles.timerText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const cancelButtonStyle = useMemo(
() => [
styles.actionButton,
styles.actionButtonCancel,
actionsDisabled && !isFailed ? styles.buttonDisabled : undefined,
],
[actionsDisabled, isFailed],
);
if (!showActiveState) {
return (
<Pressable
@@ -54,7 +72,7 @@ export function DictationControls({
disabled={disabled}
accessibilityRole="button"
accessibilityLabel="Start voice dictation"
style={[styles.micButton, disabled && styles.buttonDisabled]}
style={micButtonStyle}
>
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
</Pressable>
@@ -66,19 +84,13 @@ export function DictationControls({
<View style={styles.meterWrapper}>
<VolumeMeter volume={volume} isMuted={false} isSpeaking={false} orientation="horizontal" />
</View>
<Text style={[styles.timerText, { color: theme.colors.foreground }]}>
{formatDuration(duration)}
</Text>
<Text style={timerTextStyle}>{formatDuration(duration)}</Text>
<View style={styles.actionGroup}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityLabel="Cancel dictation"
style={[
styles.actionButton,
styles.actionButtonCancel,
actionsDisabled && !isFailed ? styles.buttonDisabled : undefined,
]}
style={cancelButtonStyle}
>
<X size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
@@ -86,32 +98,34 @@ export function DictationControls({
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.foreground} />
</View>
) : isFailed ? (
) : null}
{!actionsDisabled && isFailed ? (
<Pressable
onPress={onRetry}
accessibilityLabel="Retry dictation"
style={[styles.actionButton, styles.actionButtonConfirm]}
style={ACTION_CONFIRM_STYLE}
>
<RefreshCcw size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
) : (
) : null}
{!actionsDisabled && !isFailed ? (
<>
<Pressable
onPress={onAccept}
accessibilityLabel="Insert transcription"
style={[styles.actionButton, styles.actionButtonSecondary]}
style={ACTION_SECONDARY_STYLE}
>
<Check size={theme.iconSize.sm} color={theme.colors.foreground} />
</Pressable>
<Pressable
onPress={onAcceptAndSend}
accessibilityLabel="Insert transcription and send"
style={[styles.actionButton, styles.actionButtonConfirm]}
style={ACTION_CONFIRM_STYLE}
>
<ArrowUp size={theme.iconSize.sm} color={theme.colors.surface0} />
</Pressable>
</>
)}
) : null}
</View>
</View>
);
@@ -140,21 +154,43 @@ export function DictationOverlay({
const actionsDisabled = isProcessing;
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
const containerStyle = useMemo(
() => [overlayStyles.container, { backgroundColor: theme.colors.accent }],
[theme.colors.accent],
);
const overlayCancelButtonStyle = useMemo(
() => [
overlayStyles.cancelButton,
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
],
[actionsDisabled, isFailed],
);
const overlayTimerTextStyle = useMemo(
() => [overlayStyles.timerText, { color: theme.colors.palette.white }],
[theme.colors.palette.white],
);
const overlayTranscriptTextStyle = useMemo(
() => [overlayStyles.transcriptText, { color: theme.colors.palette.white, opacity: 0.95 }],
[theme.colors.palette.white],
);
const overlayRetryButtonStyle = useMemo(
() => [overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }],
[theme.colors.palette.white],
);
const overlayConfirmButtonStyle = overlayRetryButtonStyle;
if (!showActiveState) {
return null;
}
return (
<View style={[overlayStyles.container, { backgroundColor: theme.colors.accent }]}>
<View style={containerStyle}>
<Pressable
onPress={handleCancel}
disabled={actionsDisabled && !isFailed}
accessibilityRole="button"
accessibilityLabel="Cancel dictation"
style={[
overlayStyles.cancelButton,
actionsDisabled && !isFailed && overlayStyles.buttonDisabled,
]}
style={overlayCancelButtonStyle}
>
<X size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
</Pressable>
@@ -168,18 +204,10 @@ export function DictationOverlay({
orientation="horizontal"
color={theme.colors.palette.white}
/>
<Text style={[overlayStyles.timerText, { color: theme.colors.palette.white }]}>
{formatDuration(duration)}
</Text>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{isFailed ? (
<Text
numberOfLines={2}
style={[
overlayStyles.transcriptText,
{ color: theme.colors.palette.white, opacity: 0.95 },
]}
>
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{errorText ? `Dictation failed: ${errorText}` : "Dictation failed. Tap retry."}
</Text>
) : null}
@@ -190,22 +218,24 @@ export function DictationOverlay({
<View style={overlayStyles.loadingContainer}>
<ActivityIndicator size="small" color={theme.colors.palette.white} />
</View>
) : isFailed ? (
) : null}
{!actionsDisabled && isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
accessibilityLabel="Retry dictation"
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
style={overlayRetryButtonStyle}
>
<RefreshCcw size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
) : (
) : null}
{!actionsDisabled && !isFailed ? (
<>
<Pressable
onPress={onAccept}
accessibilityRole="button"
accessibilityLabel="Insert transcription"
style={[overlayStyles.actionButton, { backgroundColor: "rgba(255, 255, 255, 0.25)" }]}
style={OVERLAY_ACCEPT_BUTTON_STYLE}
>
<Pencil
size={theme.iconSize.lg}
@@ -217,12 +247,12 @@ export function DictationOverlay({
onPress={onAcceptAndSend}
accessibilityRole="button"
accessibilityLabel="Insert transcription and send"
style={[overlayStyles.actionButton, { backgroundColor: theme.colors.palette.white }]}
style={overlayConfirmButtonStyle}
>
<ArrowUp size={theme.iconSize.lg} color={theme.colors.accent} strokeWidth={2.5} />
</Pressable>
</>
)}
) : null}
</View>
</View>
);
@@ -364,3 +394,8 @@ const overlayStyles = StyleSheet.create((theme) => ({
justifyContent: "center",
},
}));
const ACTION_CONFIRM_STYLE = [styles.actionButton, styles.actionButtonConfirm];
const ACTION_SECONDARY_STYLE = [styles.actionButton, styles.actionButtonSecondary];
const OVERLAY_ACCEPT_BUTTON_BG = { backgroundColor: "rgba(255, 255, 255, 0.25)" };
const OVERLAY_ACCEPT_BUTTON_STYLE = [overlayStyles.actionButton, OVERLAY_ACCEPT_BUTTON_BG];

View File

@@ -8,7 +8,7 @@ import {
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { useExplorerSidebarAnimationOptional } from "@/contexts/explorer-sidebar-animation-context";
interface DiffScrollProps {
children: React.ReactNode;
@@ -20,7 +20,7 @@ interface DiffScrollProps {
export function DiffScroll({
children,
scrollViewWidth,
scrollViewWidth: _scrollViewWidth,
onScrollViewWidthChange,
style,
contentContainerStyle,
@@ -31,13 +31,8 @@ export function DiffScroll({
const scrollViewRef = useRef<ScrollViewType>(null);
// Get the close gesture ref from animation context (may not be available outside sidebar)
let closeGestureRef: React.MutableRefObject<any> | undefined;
try {
const animation = useExplorerSidebarAnimation();
closeGestureRef = animation.closeGestureRef;
} catch {
// Not inside ExplorerSidebarAnimationProvider, which is fine
}
const animation = useExplorerSidebarAnimationOptional();
const closeGestureRef = animation?.closeGestureRef;
// Register/unregister scroll offset tracking
useEffect(() => {
@@ -61,6 +56,11 @@ export function DiffScroll({
[horizontalScroll, scrollId],
);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
return (
<ScrollView
ref={scrollViewRef}
@@ -72,7 +72,7 @@ export function DiffScroll({
contentContainerStyle={contentContainerStyle}
onScroll={handleScroll}
scrollEventThrottle={16}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
onLayout={handleLayout}
// When at left edge, wait for close gesture to fail before scrolling.
// The close gesture fails quickly on leftward swipes (failOffsetX=-10),
// so scrolling left works normally. On rightward swipes, close gesture

View File

@@ -1,3 +1,4 @@
import { useCallback, useMemo } from "react";
import { ScrollView, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
@@ -16,15 +17,20 @@ export function DiffScroll({
contentContainerStyle,
}: DiffScrollProps) {
const webScrollbarStyle = useWebScrollbarStyle();
const combinedStyle = useMemo(() => [style, webScrollbarStyle], [style, webScrollbarStyle]);
const handleLayout = useCallback(
(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width),
[onScrollViewWidthChange],
);
return (
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
style={[style, webScrollbarStyle]}
style={combinedStyle}
contentContainerStyle={contentContainerStyle}
onLayout={(e: LayoutChangeEvent) => onScrollViewWidthChange(e.nativeEvent.layout.width)}
onLayout={handleLayout}
>
{children}
</ScrollView>

View File

@@ -17,6 +17,67 @@ interface DiffViewerProps {
fillAvailableHeight?: boolean;
}
function DiffLineRow({ line }: { line: DiffLine }) {
const lineContainerStyle = React.useMemo(
() => [
styles.line,
line.type === "header" && styles.headerLine,
line.type === "add" && styles.addLine,
line.type === "remove" && styles.removeLine,
line.type === "context" && styles.contextLine,
],
[line.type],
);
const plainLineTextStyle = React.useMemo(
() => [
styles.lineText,
line.type === "header" && styles.headerText,
line.type === "add" && styles.addText,
line.type === "remove" && styles.removeText,
line.type === "context" && styles.contextText,
],
[line.type],
);
return (
<View style={lineContainerStyle}>
{line.segments ? (
<Text style={styles.lineText}>
<Text style={line.type === "add" ? styles.addText : styles.removeText}>
{line.content[0]}
</Text>
{line.segments.map((segment) => (
<DiffSegment
key={`${segment.changed ? "c" : "u"}:${segment.text}`}
segment={segment}
lineType={line.type}
/>
))}
</Text>
) : (
<Text style={plainLineTextStyle}>{line.content}</Text>
)}
</View>
);
}
function DiffSegment({
segment,
lineType,
}: {
segment: NonNullable<DiffLine["segments"]>[number];
lineType: DiffLine["type"];
}) {
const segmentStyle = React.useMemo(
() => [
lineType === "add" ? styles.addText : styles.removeText,
segment.changed && (lineType === "add" ? styles.addHighlight : styles.removeHighlight),
],
[lineType, segment.changed],
);
return <Text style={segmentStyle}>{segment.text}</Text>;
}
export function DiffViewer({
diffLines,
maxHeight,
@@ -25,6 +86,29 @@ export function DiffViewer({
}: DiffViewerProps) {
const [scrollViewWidth, setScrollViewWidth] = React.useState(0);
const webScrollbarStyle = useWebScrollbarStyle();
const handleInnerLayout = React.useCallback(
(e: { nativeEvent: { layout: { width: number } } }) =>
setScrollViewWidth(e.nativeEvent.layout.width),
[],
);
const outerScrollStyle = React.useMemo(
() => [
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
],
[maxHeight, fillAvailableHeight, webScrollbarStyle],
);
const linesContainerStyle = React.useMemo(
() => [styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }],
[scrollViewWidth],
);
const keyedDiffLines = React.useMemo(
() => diffLines.map((line, index) => ({ key: `${index}-${line.type}-${line.content}`, line })),
[diffLines],
);
if (!diffLines.length) {
return (
@@ -36,12 +120,7 @@ export function DiffViewer({
return (
<ScrollView
style={[
styles.verticalScroll,
maxHeight !== undefined && { maxHeight },
fillAvailableHeight && styles.fillHeight,
webScrollbarStyle,
]}
style={outerScrollStyle}
contentContainerStyle={styles.verticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
@@ -52,52 +131,11 @@ export function DiffViewer({
showsHorizontalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={styles.horizontalContent}
onLayout={(e) => setScrollViewWidth(e.nativeEvent.layout.width)}
onLayout={handleInnerLayout}
>
<View style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}>
{diffLines.map((line, index) => (
<View
key={`${line.type}-${index}`}
style={[
styles.line,
line.type === "header" && styles.headerLine,
line.type === "add" && styles.addLine,
line.type === "remove" && styles.removeLine,
line.type === "context" && styles.contextLine,
]}
>
{line.segments ? (
<Text style={styles.lineText}>
<Text style={line.type === "add" ? styles.addText : styles.removeText}>
{line.content[0]}
</Text>
{line.segments.map((segment, segIdx) => (
<Text
key={segIdx}
style={[
line.type === "add" ? styles.addText : styles.removeText,
segment.changed &&
(line.type === "add" ? styles.addHighlight : styles.removeHighlight),
]}
>
{segment.text}
</Text>
))}
</Text>
) : (
<Text
style={[
styles.lineText,
line.type === "header" && styles.headerText,
line.type === "add" && styles.addText,
line.type === "remove" && styles.removeText,
line.type === "context" && styles.contextText,
]}
>
{line.content}
</Text>
)}
</View>
<View style={linesContainerStyle}>
{keyedDiffLines.map(({ key, line }) => (
<DiffLineRow key={key} line={line} />
))}
</View>
</ScrollView>

View File

@@ -1,12 +1,23 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { ActivityIndicator, Pressable, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Check, X, XCircle } from "lucide-react-native";
import { useDownloadStore, formatSpeed, formatEta } from "@/stores/download-store";
import { useDownloadStore, formatSpeed, formatEta, type Download } from "@/stores/download-store";
const AUTO_DISMISS_DELAY = 3000;
function getDownloadStatusText(download: Download): string {
if (download.status === "downloading") {
if (download.progress) {
return `${Math.round(download.progress.percent * 100)}% · ${formatSpeed(download.progress.speed)} · ${formatEta(download.progress.eta)}`;
}
return "Starting...";
}
if (download.status === "complete") return "Download complete";
return download.message ?? "Download failed";
}
export function DownloadToast() {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
@@ -36,53 +47,46 @@ export function DownloadToast() {
};
}, [activeDownload, dismissDownload]);
const containerStyle = useMemo(
() => [styles.container, { bottom: theme.spacing[4] + insets.bottom }],
[theme.spacing, insets.bottom],
);
const handleDismiss = useCallback(() => {
if (activeDownload) {
dismissDownload(activeDownload.id);
}
}, [activeDownload, dismissDownload]);
if (!activeDownload) {
return null;
}
return (
<View
style={[styles.container, { bottom: theme.spacing[4] + insets.bottom }]}
pointerEvents="box-none"
>
<View style={containerStyle} pointerEvents="box-none">
<View style={styles.toast}>
{activeDownload.status === "downloading" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : activeDownload.status === "complete" ? (
) : null}
{activeDownload.status === "complete" ? (
<Check size={18} color={theme.colors.primary} />
) : (
) : null}
{activeDownload.status !== "downloading" && activeDownload.status !== "complete" ? (
<XCircle size={18} color={theme.colors.destructive} />
)}
) : null}
<View style={styles.textContainer}>
<Text style={styles.fileName} numberOfLines={1}>
{activeDownload.fileName}
</Text>
<Text style={styles.status}>
{activeDownload.status === "downloading"
? activeDownload.progress
? `${Math.round(activeDownload.progress.percent * 100)}% · ${formatSpeed(activeDownload.progress.speed)} · ${formatEta(activeDownload.progress.eta)}`
: "Starting..."
: activeDownload.status === "complete"
? "Download complete"
: (activeDownload.message ?? "Download failed")}
</Text>
<Text style={styles.status}>{getDownloadStatusText(activeDownload)}</Text>
{activeDownload.status === "downloading" && activeDownload.progress && (
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${Math.round(activeDownload.progress.percent * 100)}%` },
]}
/>
<ProgressFill percent={activeDownload.progress.percent} />
</View>
)}
</View>
{activeDownload.status !== "downloading" && (
<Pressable
onPress={() => dismissDownload(activeDownload.id)}
hitSlop={8}
style={styles.dismiss}
>
<Pressable onPress={handleDismiss} hitSlop={8} style={styles.dismiss}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
)}
@@ -91,6 +95,12 @@ export function DownloadToast() {
);
}
function ProgressFill({ percent }: { percent: number }) {
const width: `${number}%` = `${Math.round(percent * 100)}%`;
const fillStyle = useMemo(() => [styles.progressFill, { width }], [width]);
return <View style={fillStyle} />;
}
const styles = StyleSheet.create((theme) => ({
container: {
position: "absolute",

View File

@@ -1,5 +1,5 @@
import { RefreshControl } from "react-native";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import DraggableFlatList, {
NestableDraggableFlatList,
type RenderItemParams,
@@ -9,6 +9,8 @@ import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-li
export type { DraggableListProps, DraggableRenderItemInfo };
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
export function DraggableList<T>({
data,
keyExtractor,
@@ -37,7 +39,15 @@ export function DraggableList<T>({
// Pass the ref directly to DraggableFlatList - it handles gesture
// coordination internally for nestable lists.
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
const simultaneousHandlers = useMemo(
() => (simultaneousGestureRef ? [simultaneousGestureRef] : undefined),
[simultaneousGestureRef],
);
const refreshColors = useMemo(
() => [theme.colors.foregroundMuted],
[theme.colors.foregroundMuted],
);
const handleRenderItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
@@ -71,11 +81,25 @@ export function DraggableList<T>({
}, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? SCROLL_ENABLED_FLEX_STYLE : undefined);
const shouldShowRefreshControl = showRefreshControl && !nestable;
const ListComponent: typeof DraggableFlatList = (
nestable ? (NestableDraggableFlatList as any) : DraggableFlatList
) as any;
nestable ? (NestableDraggableFlatList as unknown) : DraggableFlatList
) as typeof DraggableFlatList;
const refreshControl = useMemo(
() =>
shouldShowRefreshControl ? (
<RefreshControl
refreshing={refreshing ?? false}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={refreshColors}
/>
) : undefined,
[shouldShowRefreshControl, refreshing, onRefresh, theme.colors.foregroundMuted, refreshColors],
);
return (
<ListComponent
@@ -100,16 +124,7 @@ export function DraggableList<T>({
onRelease={handleRelease}
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
waitFor={waitFor}
refreshControl={
shouldShowRefreshControl ? (
<RefreshControl
refreshing={refreshing ?? false}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={[theme.colors.foregroundMuted]}
/>
) : undefined
}
refreshControl={refreshControl}
/>
);
}

View File

@@ -7,10 +7,10 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DraggableList } from "./draggable-list.web";
type DndContextProps = {
interface DndContextProps {
onDragStart?: (event: { active: { id: string } }) => void;
onDragCancel?: () => void;
};
}
let latestDndContextProps: DndContextProps | null = null;
@@ -27,7 +27,7 @@ vi.mock("@dnd-kit/core", () => ({
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: React.PropsWithChildren) => <>{children}</>,
SortableContext: ({ children }: React.PropsWithChildren) => children,
arrayMove: <T,>(items: T[], from: number, to: number) => {
const next = [...items];
const [item] = next.splice(from, 1);
@@ -90,18 +90,28 @@ afterEach(() => {
vi.unstubAllGlobals();
});
const DATA: string[] = ["alpha", "beta"];
function keyExtractor(item: string): string {
return item;
}
function renderItem({ item, isActive }: { item: string; isActive: boolean }) {
return (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
);
}
function renderList(): void {
act(() => {
root?.render(
<DraggableList
data={["alpha", "beta"]}
keyExtractor={(item) => item}
data={DATA}
keyExtractor={keyExtractor}
onDragEnd={vi.fn()}
renderItem={({ item, isActive }) => (
<div data-active={String(isActive)} data-testid={`item-${item}`}>
{item}
</div>
)}
renderItem={renderItem}
scrollEnabled={false}
/>,
);

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState, type ReactElement } from "react";
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -29,6 +29,8 @@ const restrictToVerticalAxis: Modifier = ({ transform }) => ({
x: 0,
});
const DND_MODIFIERS = [restrictToVerticalAxis];
interface SortableItemProps<T> {
id: string;
item: T;
@@ -78,12 +80,15 @@ function SortableItem<T>({
const scaleTransform = isDragging ? "scale(1.02)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
const style = {
transform: combinedTransform || undefined,
transition,
opacity: isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 1,
};
const style = useMemo(
() => ({
transform: combinedTransform || undefined,
transition,
opacity: isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 1,
}),
[combinedTransform, transition, isDragging],
);
const info: DraggableRenderItemInfo<T> = {
item,
@@ -183,12 +188,18 @@ export function DraggableList<T>({
[clearDragState, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
const wrapperStyle = [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
containerStyle,
];
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
);
const wrapperStyle = useMemo(
() => [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
containerStyle,
],
[scrollEnabled, containerStyle],
);
return (
<View style={wrapperStyle}>
@@ -209,7 +220,7 @@ export function DraggableList<T>({
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
@@ -240,7 +251,7 @@ export function DraggableList<T>({
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}

View File

@@ -126,6 +126,9 @@ export function ExplorerSidebar({
[isGit, serverId, setExplorerTabForCheckout, workspaceRoot],
);
const handleHeaderClose = useCallback(() => handleClose("header-close-button"), [handleClose]);
const handleDesktopClose = useCallback(() => handleClose("desktop-close-button"), [handleClose]);
// Swipe gesture to close (swipe right on mobile)
const closeGesture = useMemo(
() =>
@@ -253,9 +256,40 @@ export function ExplorerSidebar({
width: resizeWidth.value,
}));
const backdropCombinedStyle = useMemo(
() => [explorerStaticStyles.backdrop, backdropAnimatedStyle],
[backdropAnimatedStyle],
);
const mobileSidebarStyle = useMemo(
() => [
explorerStaticStyles.mobileSidebar,
{
width: windowWidth,
paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar,
},
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
],
[
windowWidth,
insets.top,
theme.colors.surfaceSidebar,
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
],
);
const desktopSidebarStyle = useMemo(
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
[resizeAnimatedStyle, insets.top],
);
// Mobile: full-screen overlay with gesture.
// On web, keep it interactive only while open so closed sidebars don't eat taps.
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
// Navigation stacks can keep previous screens mounted; hide sidebars for unfocused
// screens so only the active screen exposes explorer/terminal surfaces.
@@ -267,26 +301,14 @@ export function ExplorerSidebar({
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
{/* Backdrop */}
<Animated.View style={[explorerStaticStyles.backdrop, backdropAnimatedStyle]} />
<Animated.View style={backdropCombinedStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
explorerStaticStyles.mobileSidebar,
{
width: windowWidth,
paddingTop: insets.top,
backgroundColor: theme.colors.surfaceSidebar,
},
sidebarAnimatedStyle,
mobileKeyboardInsetStyle,
]}
pointerEvents="auto"
>
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("header-close-button")}
onClose={handleHeaderClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -307,19 +329,17 @@ export function ExplorerSidebar({
}
return (
<Animated.View
style={[explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<Animated.View style={desktopSidebarStyle}>
<View style={DESKTOP_SIDEBAR_BORDER_STYLE}>
{/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
<View style={RESIZE_HANDLE_STYLE} />
</GestureDetector>
<SidebarContent
activeTab={explorerTab}
onTabPress={handleTabPress}
onClose={() => handleClose("desktop-close-button")}
onClose={handleDesktopClose}
serverId={serverId}
workspaceId={workspaceId}
workspaceRoot={workspaceRoot}
@@ -333,6 +353,34 @@ export function ExplorerSidebar({
);
}
interface ExplorerTabButtonProps {
tab: ExplorerTab;
active: boolean;
label?: string;
onTabPress: (tab: ExplorerTab) => void;
testID: string;
children?: React.ReactNode;
}
function ExplorerTabButton({
tab,
active,
label,
onTabPress,
testID,
children,
}: ExplorerTabButtonProps) {
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
const tabStyle = useMemo(() => [styles.tab, active && styles.tabActive], [active]);
const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]);
return (
<Pressable testID={testID} style={tabStyle} onPress={handlePress}>
{children}
{label !== undefined ? <Text style={tabTextStyle}>{label}</Text> : null}
</Pressable>
);
}
interface SidebarContentProps {
activeTab: ExplorerTab;
onTabPress: (tab: ExplorerTab) => void;
@@ -374,37 +422,40 @@ function SidebarContent({
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
const headerStyle = useMemo(
() => [styles.header, { paddingRight: padding.right }],
[padding.right],
);
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={[styles.header, { paddingRight: padding.right }]} testID="explorer-header">
<View style={headerStyle} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
{isGit && (
<Pressable
<ExplorerTabButton
tab="changes"
active={resolvedTab === "changes"}
label="Changes"
onTabPress={onTabPress}
testID="explorer-tab-changes"
style={[styles.tab, resolvedTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text style={[styles.tabText, resolvedTab === "changes" && styles.tabTextActive]}>
Changes
</Text>
</Pressable>
/>
)}
<Pressable
<ExplorerTabButton
tab="files"
active={resolvedTab === "files"}
label="Files"
onTabPress={onTabPress}
testID="explorer-tab-files"
style={[styles.tab, resolvedTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text style={[styles.tabText, resolvedTab === "files" && styles.tabTextActive]}>
Files
</Text>
</Pressable>
/>
{isGit && hasPullRequest && (
<Pressable
<ExplorerTabButton
tab="pr"
active={resolvedTab === "pr"}
label={prTabLabel}
onTabPress={onTabPress}
testID="explorer-tab-pr"
style={[styles.tab, resolvedTab === "pr" && styles.tabActive]}
onPress={() => onTabPress("pr")}
>
<GitHubIcon
size={13}
@@ -412,10 +463,7 @@ function SidebarContent({
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
<Text style={[styles.tabText, resolvedTab === "pr" && styles.tabTextActive]}>
{prTabLabel}
</Text>
</Pressable>
</ExplorerTabButton>
)}
</View>
<View style={styles.headerRightSection}>
@@ -540,3 +588,6 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
},
}));
const DESKTOP_SIDEBAR_BORDER_STYLE = [styles.desktopSidebarBorder, { flex: 1 }];
const RESIZE_HANDLE_STYLE = [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)];

View File

@@ -1,7 +1,7 @@
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-native-reanimated";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "./message-input";
@@ -33,9 +33,14 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
pointerEvents: overlayOpacity.value > 0 ? "auto" : "none",
}));
const overlayStyle = useMemo(
() => [styles.overlay, overlayAnimatedStyle],
[overlayAnimatedStyle],
);
// On non-web platforms, just render children
if (!IS_WEB) {
return <>{children}</>;
return children;
}
return (
@@ -47,7 +52,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
{children}
{/* Drop overlay */}
<Animated.View style={[styles.overlay, overlayAnimatedStyle]}>
<Animated.View style={overlayStyle}>
{/* Backdrop */}
<View style={styles.backdrop} />
{/* Content */}

File diff suppressed because it is too large Load Diff

View File

@@ -100,25 +100,43 @@ const CodeLine = React.memo(function CodeLine({
colorMap,
baseColor,
}: CodeLineProps) {
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
const gutterTextStyle = useMemo(
() => [codeLineStyles.gutterText, { color: baseColor }],
[baseColor],
);
const keyedTokens = useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<View style={codeLineStyles.line}>
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
<View style={gutterStyle}>
<Text style={gutterTextStyle}>{String(lineNumber)}</Text>
</View>
<Text selectable style={codeLineStyles.lineText}>
{tokens.map((token, index) => (
<Text
key={index}
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
>
{token.text}
</Text>
{keyedTokens.map(({ key, token }) => (
<CodeLineToken
key={key}
color={token.style ? (colorMap[token.style] ?? baseColor) : baseColor}
text={token.text}
/>
))}
</Text>
</View>
);
});
interface CodeLineTokenProps {
color: string;
text: string;
}
function CodeLineToken({ color, text }: CodeLineTokenProps) {
const style = useMemo(() => ({ color }), [color]);
return <Text style={style}>{text}</Text>;
}
const codeLineStyles = StyleSheet.create((theme) => ({
line: {
flexDirection: "row",
@@ -171,13 +189,18 @@ function FilePreviewBody({
}
return highlightCode(preview.content ?? "", filePath);
}, [isMarkdownFile, preview?.kind, preview?.content, filePath]);
}, [isMarkdownFile, preview, filePath]);
const gutterWidth = useMemo(() => {
if (!highlightedLines) return 0;
return lineNumberGutterWidth(highlightedLines.length);
}, [highlightedLines]);
const imageSource = useMemo(
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
[imagePreviewUri],
);
if (isLoading && !preview) {
return (
<View style={styles.centerState}>
@@ -219,13 +242,18 @@ function FilePreviewBody({
}
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
const keyedLines = lines.map((tokens, index) => ({
key: `line-${index}`,
tokens,
lineNumber: index + 1,
}));
const codeLines = (
<View>
{lines.map((tokens, index) => (
{keyedLines.map(({ key, tokens, lineNumber }) => (
<CodeLine
key={index}
key={key}
tokens={tokens}
lineNumber={index + 1}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
colorMap={colorMap}
baseColor={baseColor}
@@ -287,9 +315,7 @@ function FilePreviewBody({
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
>
<RNImage
source={{
uri: imagePreviewUri,
}}
source={imageSource ?? undefined}
style={styles.previewImage}
resizeMode="contain"
/>

View File

@@ -230,15 +230,6 @@ function canPush(input: BuildGitActionsInput): boolean {
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
}
function canMergeBranch(input: BuildGitActionsInput): boolean {
return (
!input.isOnBaseBranch &&
input.baseRefAvailable &&
!input.hasUncommittedChanges &&
input.aheadCount > 0
);
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
return (
!input.isOnBaseBranch &&

View File

@@ -1,5 +1,11 @@
import { useCallback } from "react";
import { View, Text, ActivityIndicator, Pressable } from "react-native";
import { useCallback, useMemo } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import {
@@ -11,6 +17,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
@@ -19,6 +26,52 @@ interface GitActionsSplitButtonProps {
hideLabels?: boolean;
}
interface GitActionMenuItemProps {
action: GitAction;
onSelect: (action: GitAction) => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
needsSeparator?: boolean;
showSeparator?: boolean;
closeOnSelect?: boolean;
}
function GitActionMenuItem({
action,
onSelect,
archiveShortcutKeys,
needsSeparator,
showSeparator,
closeOnSelect,
}: GitActionMenuItemProps) {
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
const trailing = useMemo(
() =>
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined,
[action.id, archiveShortcutKeys],
);
return (
<View>
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={trailing}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={closeOnSelect}
onSelect={handleSelect}
>
{action.label}
</DropdownMenuItem>
</View>
);
}
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
@@ -44,17 +97,33 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
[theme.colors.foreground, toast],
);
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
const primaryDisabled = gitActions.primary?.disabled;
const primaryPressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
primaryDisabled && styles.splitButtonPrimaryDisabled,
],
[primaryDisabled],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
],
[],
);
return (
<View style={styles.row}>
{gitActions.primary ? (
<View style={styles.splitButton}>
<Pressable
testID="changes-primary-cta"
style={({ hovered, pressed }) => [
styles.splitButtonPrimary,
(hovered || pressed) && styles.splitButtonPrimaryHovered,
gitActions.primary!.disabled && styles.splitButtonPrimaryDisabled,
]}
style={primaryPressableStyle}
onPress={gitActions.primary.handler}
disabled={gitActions.primary.disabled}
accessibilityRole="button"
@@ -81,47 +150,28 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-primary-cta-caret"
style={({ hovered, pressed, open }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
]}
style={caretTriggerStyle}
accessibilityRole="button"
accessibilityLabel="More options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => {
const needsSeparator =
action.id === "merge-from-base" || action.id === "archive-worktree";
return (
<View key={action.id}>
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined
}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={
action.status === "idle" &&
action.id === "pr" &&
action.label === "View PR"
}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>
</View>
);
})}
{gitActions.secondary.map((action, index) => (
<GitActionMenuItem
key={action.id}
action={action}
onSelect={handleActionSelect}
archiveShortcutKeys={archiveShortcutKeys}
needsSeparator={
action.id === "merge-from-base" || action.id === "archive-worktree"
}
showSeparator={index > 0}
closeOnSelect={
action.status === "idle" && action.id === "pr" && action.label === "View PR"
}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
@@ -132,7 +182,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
<DropdownMenuTrigger
testID="changes-overflow-menu"
hitSlop={8}
style={[styles.iconButton, styles.overflowMenuButton]}
style={overflowMenuButtonStyle}
accessibilityRole="button"
accessibilityLabel="More actions"
>
@@ -140,20 +190,12 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
{gitActions.menu.map((action) => (
<DropdownMenuItem
<GitActionMenuItem
key={action.id}
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
action={action}
onSelect={handleActionSelect}
closeOnSelect={false}
onSelect={() => handleActionSelect(action)}
>
{action.label}
</DropdownMenuItem>
/>
))}
</DropdownMenuContent>
</DropdownMenu>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useCallback, type ReactNode } from "react";
import { Pressable } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -13,15 +13,26 @@ interface BackHeaderProps {
onBack?: () => void;
}
function goBack(): void {
router.back();
}
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const handleBack = useCallback(() => {
if (onBack) {
onBack();
return;
}
goBack();
}, [onBack]);
return (
<ScreenHeader
left={
<>
<Pressable
onPress={onBack ?? (() => router.back())}
onPress={handleBack}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"

View File

@@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from "react";
import { useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -39,19 +39,19 @@ export function HeaderToggleButton({
const expandedState = (props.accessibilityState as { expanded?: boolean } | undefined)?.expanded;
const ariaExpandedProps =
isWeb && typeof expandedState === "boolean"
? ({ "aria-expanded": expandedState } as any)
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null;
const combinedStyle = useMemo(() => [headerIconSlotStyle.slot, style], [style]);
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
{...props}
{...ariaExpandedProps}
disabled={disabled}
onPress={(e) => {
onPress(e);
}}
style={[headerIconSlotStyle.slot, style]}
onPress={onPress}
style={combinedStyle}
>
{typeof children === "function"
? (state: { pressed: boolean; hovered?: boolean }) =>

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useCallback, useMemo, type ReactNode } from "react";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
@@ -27,13 +27,16 @@ const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2;
function MobileMenuIcon({ color }: { color: string }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
const shortLineStyle = useMemo(
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }],
[color],
);
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }]}
/>
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
@@ -48,14 +51,23 @@ export function SidebarMenuToggle({
const isMobile = useIsCompactFormFactor();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const toggleShortcutKeys = useMemo(
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
[],
);
const menuIconColor =
!isMobile && isOpen ? theme.colors.foreground : theme.colors.foregroundMuted;
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
return (
<HeaderToggleButton
onPress={() => toggleAgentListForLayout({ isCompact: isMobile })}
onPress={handlePress}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
@@ -65,7 +77,7 @@ export function SidebarMenuToggle({
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import type { LayoutChangeEvent } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -43,23 +43,31 @@ export function ScreenHeader({
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const baseHorizontalPadding = theme.spacing[2];
const innerStyle = useMemo(
() => [styles.inner, { paddingTop: insets.top + topPadding }],
[insets.top, topPadding],
);
const rowStyle = useMemo(
() => [
styles.row,
{
paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + padding.right,
},
borderless && styles.borderless,
],
[baseHorizontalPadding, padding.left, padding.right, borderless],
);
const leftCombinedStyle = useMemo(() => [styles.left, leftStyle], [leftStyle]);
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
return (
<View style={styles.header}>
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
<View
onLayout={onRowLayout}
style={[
styles.row,
{
paddingLeft: baseHorizontalPadding + padding.left,
paddingRight: baseHorizontalPadding + padding.right,
},
borderless && styles.borderless,
]}
>
<View style={innerStyle}>
<View onLayout={onRowLayout} style={rowStyle}>
<TitlebarDragRegion />
<View style={[styles.left, leftStyle]}>{left}</View>
<View style={[styles.right, rightStyle]}>{right}</View>
<View style={leftCombinedStyle}>{left}</View>
<View style={rightCombinedStyle}>{right}</View>
</View>
</View>
</View>

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
@@ -15,8 +15,9 @@ interface ScreenTitleProps {
* HeaderIconBadge) — never nested inside this component.
*/
export function ScreenTitle({ children, numberOfLines = 1, testID, style }: ScreenTitleProps) {
const combinedStyle = useMemo(() => [styles.text, style], [style]);
return (
<Text style={[styles.text, style]} numberOfLines={numberOfLines} testID={testID}>
<Text style={combinedStyle} numberOfLines={numberOfLines} testID={testID}>
{children}
</Text>
);

View File

@@ -12,5 +12,5 @@ export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
return <>{children}</>;
return children;
}

View File

@@ -1,4 +1,5 @@
import { SquareTerminal } from "lucide-react-native";
import { useMemo } from "react";
import { Image, type ImageSourcePropType } from "react-native";
import {
isKnownEditorTargetId,
@@ -29,15 +30,10 @@ export function hasBundledEditorAppIcon(editorId: EditorTargetId): editorId is K
}
export function EditorAppIcon({ editorId, size = 16, color }: EditorAppIconProps) {
const imageStyle = useMemo(() => ({ width: size, height: size }), [size]);
if (!hasBundledEditorAppIcon(editorId)) {
return <SquareTerminal size={size} color={color} />;
}
return (
<Image
source={EDITOR_APP_IMAGES[editorId]}
style={{ width: size, height: size }}
resizeMode="contain"
/>
);
return <Image source={EDITOR_APP_IMAGES[editorId]} style={imageStyle} resizeMode="contain" />;
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout";
@@ -8,6 +8,8 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"];
export function KeyboardShortcutsDialog() {
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
const setOpen = useKeyboardShortcutsStore((s) => s.setShortcutsDialogOpen);
@@ -19,13 +21,15 @@ export function KeyboardShortcutsDialog() {
[isDesktopApp, isMac],
);
const handleClose = useCallback(() => setOpen(false), [setOpen]);
return (
<AdaptiveModalSheet
title="Shortcuts"
visible={open}
onClose={() => setOpen(false)}
onClose={handleClose}
testID="keyboard-shortcuts-dialog"
snapPoints={["70%", "92%"]}
snapPoints={SNAP_POINTS}
>
<View testID="keyboard-shortcuts-dialog-content" style={styles.content}>
{sections.map((section) => (

View File

@@ -7,13 +7,13 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { panelState, useSidebarWorkspacesListMock, theme } = vi.hoisted(() => {
const panelState = {
const hoistedPanelState = {
isOpen: false,
showMobileAgent: vi.fn(),
};
return {
panelState,
panelState: hoistedPanelState,
useSidebarWorkspacesListMock: vi.fn(),
theme: {
spacing: { 0: 0, 0.5: 2, 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 5: 20 },

View File

@@ -18,6 +18,9 @@ import {
Text,
useWindowDimensions,
View,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
@@ -142,12 +145,11 @@ export const LeftSidebar = memo(function LeftSidebar({
const activeHostStatus = activeServerId
? (activeHostSnapshot?.connectionStatus ?? "connecting")
: "idle";
const activeHostStatusColor =
activeHostStatus === "online"
? theme.colors.palette.green[400]
: activeHostStatus === "connecting"
? theme.colors.palette.amber[500]
: theme.colors.palette.red[500];
let activeHostStatusColor: string;
if (activeHostStatus === "online") activeHostStatusColor = theme.colors.palette.green[400];
else if (activeHostStatus === "connecting")
activeHostStatusColor = theme.colors.palette.amber[500];
else activeHostStatusColor = theme.colors.palette.red[500];
const hostOptions = useMemo(
() =>
daemons.map((daemon) => ({
@@ -288,6 +290,44 @@ export const LeftSidebar = memo(function LeftSidebar({
);
});
interface HostPickerTriggerProps {
triggerRef: React.Ref<View>;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
hostOptionsEmpty: boolean;
hostStatusDotStyle: StyleProp<ViewStyle>;
activeHostLabel: string;
}
function HostPickerTrigger({
triggerRef,
setIsHostPickerOpen,
hostOptionsEmpty,
hostStatusDotStyle,
activeHostLabel,
}: HostPickerTriggerProps) {
const pressableStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.hostTrigger,
Boolean(hovered) && styles.hostTriggerHovered,
],
[],
);
const handlePress = useCallback(() => setIsHostPickerOpen(true), [setIsHostPickerOpen]);
return (
<Pressable
ref={triggerRef}
style={pressableStyle}
onPress={handlePress}
disabled={hostOptionsEmpty}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
);
}
function HostSwitchOption({
serverId,
label,
@@ -315,6 +355,131 @@ function HostSwitchOption({
);
}
function FooterIconButton({
onPress,
testID,
accessibilityLabel,
icon: Icon,
theme,
}: {
onPress: () => void;
testID: string;
accessibilityLabel: string;
icon: typeof Plus;
theme: SidebarTheme;
}) {
return (
<Pressable
style={styles.footerIconButton}
testID={testID}
nativeID={testID}
collapsable={false}
accessible
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={onPress}
>
{({ hovered }) => (
<Icon
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
);
}
function AddProjectTooltipContent({
newAgentKeys,
}: {
newAgentKeys: ReturnType<typeof useShortcutKeys>;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
);
}
function SidebarFooter({
theme,
activeServerId,
activeHostLabel,
hostStatusDotStyle,
hostOptions,
hostTriggerRef,
isHostPickerOpen,
setIsHostPickerOpen,
handleHostSelect,
renderHostOption,
handleOpenProject,
handleSettings,
}: {
theme: SidebarTheme;
activeServerId: string | null;
activeHostLabel: string;
hostStatusDotStyle: StyleProp<ViewStyle>;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
isHostPickerOpen: boolean;
setIsHostPickerOpen: Dispatch<SetStateAction<boolean>>;
handleHostSelect: (nextServerId: string) => void;
renderHostOption: SidebarSharedProps["renderHostOption"];
handleOpenProject: () => void;
handleSettings: () => void;
}) {
const newAgentKeys = useShortcutKeys("new-agent");
return (
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<HostPickerTrigger
triggerRef={hostTriggerRef}
setIsHostPickerOpen={setIsHostPickerOpen}
hostOptionsEmpty={hostOptions.length === 0}
hostStatusDotStyle={hostStatusDotStyle}
activeHostLabel={activeHostLabel}
/>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<FooterIconButton
onPress={handleOpenProject}
testID="sidebar-add-project"
accessibilityLabel="Add project"
icon={Plus}
theme={theme}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<AddProjectTooltipContent newAgentKeys={newAgentKeys} />
</TooltipContent>
</Tooltip>
<FooterIconButton
onPress={handleSettings}
testID="sidebar-settings"
accessibilityLabel="Settings"
icon={Settings}
theme={theme}
/>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
);
}
function MobileSidebar({
theme,
activeServerId,
@@ -342,7 +507,6 @@ function MobileSidebar({
closeToAgent,
handleViewMoreNavigate,
}: MobileSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const {
@@ -482,22 +646,31 @@ function MobileSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
const overlayPointerEvents = isWeb ? (isOpen ? "auto" : "none") : "box-none";
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
else if (isOpen) overlayPointerEvents = "auto";
else overlayPointerEvents = "none";
const backdropStyle = useMemo(
() => [staticStyles.backdrop, backdropAnimatedStyle],
[backdropAnimatedStyle],
);
const mobileSidebarStyle = useMemo(
() => [
staticStyles.mobileSidebar,
mobileSidebarInsetStyle,
sidebarAnimatedStyle,
{ backgroundColor: theme.colors.surfaceSidebar },
],
[mobileSidebarInsetStyle, sidebarAnimatedStyle, theme.colors.surfaceSidebar],
);
return (
<View style={StyleSheet.absoluteFillObject} pointerEvents={overlayPointerEvents}>
<Animated.View style={[staticStyles.backdrop, backdropAnimatedStyle]} />
<Animated.View style={backdropStyle} />
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View
style={[
staticStyles.mobileSidebar,
mobileSidebarInsetStyle,
sidebarAnimatedStyle,
{ backgroundColor: theme.colors.surfaceSidebar },
]}
pointerEvents="auto"
>
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<SidebarHeaderRow
icon={MessagesSquare}
@@ -524,82 +697,20 @@ function MobileSidebar({
/>
)}
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
/>
</View>
</Animated.View>
</GestureDetector>
@@ -632,7 +743,6 @@ function DesktopSidebar({
isOpen,
handleViewMore,
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const padding = useWindowControlsPadding("sidebar");
@@ -679,18 +789,27 @@ function DesktopSidebar({
width: resizeWidth.value,
}));
const paddingTopSpacerStyle = useMemo(() => ({ height: padding.top }), [padding.top]);
const desktopSidebarStyle = useMemo(
() => [staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }],
[resizeAnimatedStyle, insetsTop],
);
const desktopSidebarBorderStyle = useMemo(() => [styles.desktopSidebarBorder, { flex: 1 }], []);
const resizeHandleStyle = useMemo(
() => [styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as object)],
[],
);
if (!isOpen) {
return null;
}
return (
<Animated.View
style={[staticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insetsTop }]}
>
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
<Animated.View style={desktopSidebarStyle}>
<View style={desktopSidebarBorderStyle}>
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
@@ -717,86 +836,24 @@ function DesktopSidebar({
<SidebarCalloutSlot />
<View style={styles.sidebarFooter}>
<View style={styles.footerHostSlot}>
<Pressable
ref={hostTriggerRef}
style={({ hovered = false }) => [
styles.hostTrigger,
hovered && styles.hostTriggerHovered,
]}
onPress={() => setIsHostPickerOpen(true)}
disabled={hostOptions.length === 0}
>
<View style={hostStatusDotStyle} />
<Text style={styles.hostTriggerText} numberOfLines={1}>
{activeHostLabel}
</Text>
</Pressable>
</View>
<View style={styles.footerIconRow}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={styles.footerIconButton}
testID="sidebar-add-project"
nativeID="sidebar-add-project"
collapsable={false}
accessible
accessibilityLabel="Add project"
accessibilityRole="button"
onPress={handleOpenProject}
>
{({ hovered }) => (
<Plus
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Add project</Text>
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
</View>
</TooltipContent>
</Tooltip>
<Pressable
style={styles.footerIconButton}
testID="sidebar-settings"
nativeID="sidebar-settings"
collapsable={false}
accessible
accessibilityLabel="Settings"
accessibilityRole="button"
onPress={handleSettings}
>
{({ hovered }) => (
<Settings
size={theme.iconSize.md}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</View>
<Combobox
options={hostOptions}
value={activeServerId ?? ""}
onSelect={handleHostSelect}
renderOption={renderHostOption}
searchable={false}
title="Switch host"
searchPlaceholder="Search hosts..."
open={isHostPickerOpen}
onOpenChange={setIsHostPickerOpen}
anchorRef={hostTriggerRef}
/>
</View>
<SidebarFooter
theme={theme}
activeServerId={activeServerId}
activeHostLabel={activeHostLabel}
hostStatusDotStyle={hostStatusDotStyle}
hostOptions={hostOptions}
hostTriggerRef={hostTriggerRef}
isHostPickerOpen={isHostPickerOpen}
setIsHostPickerOpen={setIsHostPickerOpen}
handleHostSelect={handleHostSelect}
renderHostOption={renderHostOption}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
/>
{/* Resize handle - absolutely positioned over right border */}
<GestureDetector gesture={resizeGesture}>
<View style={[styles.resizeHandle, isWeb && ({ cursor: "col-resize" } as any)]} />
<View style={resizeHandleStyle} />
</GestureDetector>
</View>
</Animated.View>

View File

@@ -5,6 +5,10 @@ import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type AttachmentMenuItem, type MessageInputRef } from "./message-input";
const EMPTY_ATTACHMENTS: React.ComponentProps<typeof MessageInput>["attachments"] = [];
const EMPTY_ATTACHMENT_MENU_ITEMS: AttachmentMenuItem[] = [];
const FAKE_CONNECTED_CLIENT = { isConnected: true } as never;
const { startDictationMock, cancelDictationMock, confirmDictationMock } = vi.hoisted(() => ({
startDictationMock: vi.fn(),
cancelDictationMock: vi.fn(),
@@ -138,7 +142,7 @@ vi.mock("@/components/ui/shortcut", () => ({
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Tooltip: ({ children }: { children: React.ReactNode }) => children,
TooltipTrigger: ({
asChild,
children,
@@ -146,19 +150,19 @@ vi.mock("@/components/ui/tooltip", () => ({
}: {
asChild?: boolean;
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
} & Record<string, any>) =>
} & Record<string, unknown>) =>
asChild ? (
<>{children}</>
children
) : (
<button type="button" aria-label={props.accessibilityLabel}>
<button type="button" aria-label={props.accessibilityLabel as string | undefined}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenu: ({ children }: { children: React.ReactNode }) => children,
DropdownMenuTrigger: ({
children,
testID,
@@ -251,10 +255,10 @@ function renderMessageInput(
value={value}
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={menuItems}
client={{ isConnected: true } as never}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
submitIcon={submitIcon}
onQueue={vi.fn()}
@@ -346,10 +350,10 @@ describe("MessageInput dictation shortcuts", () => {
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
isReadyForDictation={false}
onQueue={vi.fn()}
@@ -370,10 +374,10 @@ describe("MessageInput dictation shortcuts", () => {
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
attachments={EMPTY_ATTACHMENTS}
cwd="/repo"
attachmentMenuItems={[]}
client={{ isConnected: true } as never}
attachmentMenuItems={EMPTY_ATTACHMENT_MENU_ITEMS}
client={FAKE_CONNECTED_CLIENT}
isAgentRunning={false}
isReadyForDictation
onQueue={vi.fn()}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -11,6 +11,8 @@ import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const styles = StyleSheet.create((theme) => ({
helper: {
color: theme.colors.foregroundMuted,
@@ -72,6 +74,11 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
inputRef.current?.clear();
}, []);
const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
);
const handleClose = useCallback(() => {
if (isSaving) return;
clearInput();
@@ -151,6 +158,14 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
}
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
const handleChangeOfferUrl = useCallback((next: string) => {
offerUrlRef.current = next;
}, []);
const handleSavePress = useCallback(() => {
void handleSave();
}, [handleSave]);
return (
<AdaptiveModalSheet
title="Paste pairing link"
@@ -167,9 +182,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel="pair-link-input"
onChangeText={(next) => {
offerUrlRef.current = next;
}}
onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -183,7 +196,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
<View style={styles.actions}>
<Button
style={{ flex: 1 }}
style={FLEX_ONE_STYLE}
variant="secondary"
onPress={handleCancel}
disabled={isSaving}
@@ -194,14 +207,14 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
Cancel
</Button>
<Button
style={{ flex: 1 }}
style={FLEX_ONE_STYLE}
variant="default"
onPress={() => void handleSave()}
onPress={handleSavePress}
disabled={isSaving}
testID="pair-link-submit"
accessibilityRole="button"
accessibilityLabel="Pair"
leftIcon={<Link size={16} color={theme.colors.palette.white} />}
leftIcon={pairIcon}
>
{isSaving ? "Pairing..." : "Pair"}
</Button>

View File

@@ -1,78 +1,167 @@
import type { ReactNode } from "react";
import { Text, View } from "react-native";
import Markdown from "react-native-markdown-display";
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import Markdown, { type ASTNode } from "react-native-markdown-display";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { getMarkdownListMarker } from "@/utils/markdown-list";
type MarkdownRuleStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
function MarkdownInlineText({
textKey,
inheritedStyle,
ruleStyle,
children,
}: {
textKey: string;
inheritedStyle: StyleProp<TextStyle>;
ruleStyle: StyleProp<TextStyle>;
children: ReactNode;
}) {
const style = useMemo(() => [inheritedStyle, ruleStyle], [inheritedStyle, ruleStyle]);
return (
<Text key={textKey} style={style}>
{children}
</Text>
);
}
function MarkdownListItemContent({
contentStyle,
children,
}: {
contentStyle: StyleProp<ViewStyle>;
children: ReactNode;
}) {
const style = useMemo(() => [contentStyle, LIST_ITEM_CONTENT_INNER], [contentStyle]);
return <View style={style}>{children}</View>;
}
function MarkdownParagraph({
textKey,
paragraphStyle,
isLastChild,
children,
}: {
textKey: string;
paragraphStyle: StyleProp<ViewStyle>;
isLastChild: boolean;
children: ReactNode;
}) {
const style = useMemo<StyleProp<ViewStyle>>(
() => [paragraphStyle, isLastChild ? PARAGRAPH_LAST_CHILD : null],
[paragraphStyle, isLastChild],
);
return (
<View key={textKey} style={style}>
{children}
</View>
);
}
function createPlanMarkdownRules() {
return {
text: (
node: any,
node: ASTNode,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
_parent: ASTNode[],
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.text]}>
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.text}
>
{node.content}
</Text>
</MarkdownInlineText>
),
textgroup: (
node: any,
node: ASTNode,
children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
_parent: ASTNode[],
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.textgroup]}>
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.textgroup}
>
{children}
</Text>
</MarkdownInlineText>
),
code_block: (
node: any,
node: ASTNode,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
_parent: ASTNode[],
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_block]}>
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.code_block}
>
{node.content}
</Text>
</MarkdownInlineText>
),
fence: (
node: any,
node: ASTNode,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
_parent: ASTNode[],
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.fence]}>
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.fence}
>
{node.content}
</Text>
</MarkdownInlineText>
),
code_inline: (
node: any,
node: ASTNode,
_children: ReactNode[],
_parent: any,
styles: any,
inheritedStyles: any = {},
_parent: ASTNode[],
styles: MarkdownRuleStyles,
inheritedStyles: TextStyle = {},
) => (
<Text key={node.key} style={[inheritedStyles, styles.code_inline]}>
<MarkdownInlineText
textKey={node.key}
inheritedStyle={inheritedStyles}
ruleStyle={styles.code_inline}
>
{node.content}
</Text>
</MarkdownInlineText>
),
bullet_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
bullet_list: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownRuleStyles,
) => (
<View key={node.key} style={styles.bullet_list}>
{children}
</View>
),
ordered_list: (node: any, children: ReactNode[], _parent: any, styles: any) => (
ordered_list: (
node: ASTNode,
children: ReactNode[],
_parent: ASTNode[],
styles: MarkdownRuleStyles,
) => (
<View key={node.key} style={styles.ordered_list}>
{children}
</View>
),
list_item: (node: any, children: ReactNode[], parent: any, styles: any) => {
list_item: (
node: ASTNode,
children: ReactNode[],
parent: ASTNode[],
styles: MarkdownRuleStyles,
) => {
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;
@@ -80,16 +169,25 @@ function createPlanMarkdownRules() {
return (
<View key={node.key} style={styles.list_item}>
<Text style={iconStyle}>{marker}</Text>
<View style={[contentStyle, { flex: 1, flexShrink: 1, minWidth: 0 }]}>{children}</View>
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
</View>
);
},
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
paragraph: (
node: ASTNode,
children: ReactNode[],
parent: ASTNode[],
styles: MarkdownRuleStyles,
) => {
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
return (
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
<MarkdownParagraph
textKey={node.key}
paragraphStyle={styles.paragraph}
isLastChild={isLastChild}
>
{children}
</View>
</MarkdownParagraph>
);
},
};
@@ -112,23 +210,30 @@ export function PlanCard({
const markdownStyles = createMarkdownStyles(theme);
const markdownRules = createPlanMarkdownRules();
const containerStyle = useMemo(
() => [
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[disableOuterSpacing, theme.colors.surface1, theme.colors.border],
);
const titleStyle = useMemo(
() => [styles.title, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const descriptionStyle = useMemo(
() => [styles.description, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<View
style={[
styles.container,
disableOuterSpacing && styles.containerCompact,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>
{description ? (
<Text style={[styles.description, { color: theme.colors.foregroundMuted }]}>
{description}
</Text>
) : null}
<View style={containerStyle}>
<Text style={titleStyle}>{title}</Text>
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
<Markdown style={markdownStyles} rules={markdownRules}>
{text}
</Markdown>
@@ -160,3 +265,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
}));
const LIST_ITEM_CONTENT_INNER = { flex: 1, flexShrink: 1, minWidth: 0 };
const PARAGRAPH_LAST_CHILD = { marginBottom: 0 };

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
@@ -24,11 +24,31 @@ import type {
PrState,
} from "@/utils/pr-pane-data";
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.row, Boolean(hovered) && styles.hoverable];
}
function activityPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.activityRow, Boolean(hovered) && styles.hoverable];
}
export function PrPane({ data }: { data: PrPaneData }) {
const { theme } = useUnistyles();
const [checksOpen, setChecksOpen] = useState(true);
const [reviewsOpen, setReviewsOpen] = useState(true);
const handleOpenPrUrl = useCallback(() => {
void openExternalUrl(data.url);
}, [data.url]);
const handleToggleChecks = useCallback(() => {
setChecksOpen((o) => !o);
}, []);
const handleToggleReviews = useCallback(() => {
setReviewsOpen((o) => !o);
}, []);
const passed = data.checks.filter((c) => c.status === "success").length;
const failed = data.checks.filter((c) => c.status === "failure").length;
const pending = data.checks.filter((c) => c.status === "pending").length;
@@ -46,15 +66,37 @@ export function PrPane({ data }: { data: PrPaneData }) {
const stateColor = getStateColor(data.state, theme);
const StateIcon = getStateIcon(data.state);
const stateLabel = getStateLabel(data.state);
const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]);
const keyedActivity = useMemo(
() => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })),
[data.activity],
);
const checkSuccessIcon = useMemo(
() => <CircleCheck size={12} color={theme.colors.statusSuccess} />,
[theme.colors.statusSuccess],
);
const checkDangerIcon = useMemo(
() => <CircleX size={12} color={theme.colors.statusDanger} />,
[theme.colors.statusDanger],
);
const checkWarningIcon = useMemo(
() => <CircleDot size={12} color={theme.colors.statusWarning} />,
[theme.colors.statusWarning],
);
const commentIcon = useMemo(
() => <MessageSquare size={11} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<View style={styles.root}>
<Pressable onPress={() => void openExternalUrl(data.url)} style={styles.header}>
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
<>
<View style={styles.stateLine}>
<StateIcon size={14} color={stateColor} />
<Text style={[styles.stateLabel, { color: stateColor }]}>{stateLabel}</Text>
<Text style={stateLabelStyle}>{stateLabel}</Text>
</View>
<Text style={styles.title} numberOfLines={3}>
{data.title}
@@ -74,29 +116,25 @@ export function PrPane({ data }: { data: PrPaneData }) {
<Section
title="Checks"
open={checksOpen}
onToggle={() => setChecksOpen((o) => !o)}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
color={theme.colors.statusSuccess}
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
/>
<SummaryPill
count={failed}
color={theme.colors.statusDanger}
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
icon={checkSuccessIcon}
/>
<SummaryPill count={failed} color={theme.colors.statusDanger} icon={checkDangerIcon} />
<SummaryPill
count={pending}
color={theme.colors.statusWarning}
icon={<CircleDot size={12} color={theme.colors.statusWarning} />}
icon={checkWarningIcon}
/>
</>
}
>
{data.checks.map((check, idx) => (
<CheckRow key={`${check.name}-${idx}`} check={check} />
{data.checks.map((check) => (
<CheckRow key={check.name} check={check} />
))}
</Section>
@@ -105,29 +143,29 @@ export function PrPane({ data }: { data: PrPaneData }) {
<Section
title="Reviews"
open={reviewsOpen}
onToggle={() => setReviewsOpen((o) => !o)}
onToggle={handleToggleReviews}
summary={
<>
<SummaryPill
count={approvals}
color={theme.colors.statusSuccess}
icon={<CircleCheck size={12} color={theme.colors.statusSuccess} />}
icon={checkSuccessIcon}
/>
<SummaryPill
count={changesRequested}
color={theme.colors.statusDanger}
icon={<CircleX size={12} color={theme.colors.statusDanger} />}
icon={checkDangerIcon}
/>
<SummaryPill
count={commentCount}
color={theme.colors.foregroundMuted}
icon={<MessageSquare size={11} color={theme.colors.foregroundMuted} />}
icon={commentIcon}
/>
</>
}
>
{data.activity.map((item, idx) => (
<ActivityRow key={`${item.author}-${idx}`} item={item} />
{keyedActivity.map(({ key, item }) => (
<ActivityRow key={key} item={item} />
))}
</Section>
</View>
@@ -177,21 +215,22 @@ function SummaryPill({
color: string;
icon: React.ReactNode;
}) {
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
if (count === 0) return null;
return (
<View style={styles.summaryPill}>
{icon}
<Text style={[styles.summaryPillText, { color }]}>{count}</Text>
<Text style={textStyle}>{count}</Text>
</View>
);
}
function CheckRow({ check }: { check: PrPaneCheck }) {
const handlePress = useCallback(() => {
void openExternalUrl(check.url);
}, [check.url]);
return (
<Pressable
onPress={() => void openExternalUrl(check.url)}
style={({ hovered }) => [styles.row, hovered && styles.hoverable]}
>
<Pressable onPress={handlePress} style={rowPressableStyle}>
<CheckStatusIcon status={check.status} />
<Text style={styles.rowTitle} numberOfLines={1}>
{check.name}
@@ -216,12 +255,16 @@ function CheckStatusIcon({ status }: { status: CheckStatus }) {
function ActivityRow({ item }: { item: PrPaneActivity }) {
const verb = getActivityVerb(item);
const handlePress = useCallback(() => {
void openExternalUrl(item.url);
}, [item.url]);
const avatarStyle = useMemo(
() => [styles.avatar, { backgroundColor: item.avatarColor }],
[item.avatarColor],
);
return (
<Pressable
onPress={() => void openExternalUrl(item.url)}
style={({ hovered }) => [styles.activityRow, hovered && styles.hoverable]}
>
<View style={[styles.avatar, { backgroundColor: item.avatarColor }]}>
<Pressable onPress={handlePress} style={activityPressableStyle}>
<View style={avatarStyle}>
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
</View>
<View style={styles.activityMain}>

View File

@@ -1,5 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Modal, Pressable, ScrollView, Text, TextInput, View } from "react-native";
import {
Modal,
Pressable,
ScrollView,
Text,
TextInput,
View,
type PressableStateCallbackType,
} from "react-native";
import { Folder } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
@@ -12,6 +20,44 @@ import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-sugg
import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
interface PathRowProps {
path: string;
active: boolean;
onSelect: (path: string) => void;
}
function PathRow({ path, active, onSelect }: PathRowProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
void onSelect(path);
}, [onSelect, path]);
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.row,
(Boolean(hovered) || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
],
[active, theme.colors.surface1],
);
const rowTextStyle = useMemo(
() => [styles.rowText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
return (
<Pressable style={pressableStyle} onPress={handlePress}>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
</View>
<Text style={rowTextStyle} numberOfLines={1}>
{shortenPath(path)}
</Text>
</View>
</Pressable>
);
}
export function ProjectPickerModal() {
const { theme } = useUnistyles();
const serverId = useActiveServerId();
@@ -86,6 +132,11 @@ export function ProjectPickerModal() {
void handleSelectPath(trimmed);
}, [handleSelectPath, query]);
const handleChangeQuery = useCallback((text: string) => {
setQuery(text);
setActiveIndex(0);
}, []);
// Reset state when opening/closing
useEffect(() => {
if (open) {
@@ -145,6 +196,29 @@ export function ProjectPickerModal() {
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]);
const panelStyle = useMemo(
() => [
styles.panel,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
],
[theme.colors.border, theme.colors.surface0],
);
const headerStyle = useMemo(
() => [styles.header, { borderBottomColor: theme.colors.border }],
[theme.colors.border],
);
const inputStyle = useMemo(
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const emptyTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
if (!serverId) return null;
return (
@@ -152,26 +226,15 @@ export function ProjectPickerModal() {
<View style={styles.overlay}>
<Pressable style={styles.backdrop} onPress={handleClose} />
<View
style={[
styles.panel,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
},
]}
>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
<View style={panelStyle}>
<View style={headerStyle}>
<TextInput
ref={inputRef}
value={query}
onChangeText={(text) => {
setQuery(text);
setActiveIndex(0);
}}
onChangeText={handleChangeQuery}
placeholder="Type a directory path..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[styles.input, { color: theme.colors.foreground }]}
style={inputStyle}
autoCapitalize="none"
autoCorrect={false}
autoFocus
@@ -185,49 +248,22 @@ export function ProjectPickerModal() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{isSubmitting ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Opening project...
</Text>
) : options.length === 0 && !query.trim() ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
Start typing a path
</Text>
) : (
{isSubmitting ? <Text style={emptyTextStyle}>Opening project...</Text> : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
<Text style={emptyTextStyle}>Start typing a path</Text>
) : null}
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
<>
{options.map((path, index) => {
const active = index === activeIndex;
return (
<Pressable
key={path}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => void handleSelectPath(path)}
>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Folder
size={16}
strokeWidth={2.2}
color={theme.colors.foregroundMuted}
/>
</View>
<Text
style={[styles.rowText, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{shortenPath(path)}
</Text>
</View>
</Pressable>
);
})}
{options.map((path, index) => (
<PathRow
key={path}
path={path}
active={index === activeIndex}
onSelect={handleSelectPath}
/>
))}
</>
)}
) : null}
</ScrollView>
</View>
</View>
@@ -264,7 +300,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg,
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as any,
} as object,
results: {
flexGrow: 0,
},

View File

@@ -1,6 +1,13 @@
import { AlertCircle, RotateCw, Search } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import {
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
ScrollView,
Text,
View,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
@@ -19,6 +26,58 @@ interface ProviderDiagnosticSheetProps {
serverId: string;
}
function ModelRow({ model, isFirst }: { model: AgentModelDefinition; isFirst: boolean }) {
const rowStyle = useMemo(
() => [sheetStyles.modelRow, !isFirst && sheetStyles.modelRowBorder],
[isFirst],
);
return (
<View style={rowStyle}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
);
}
function DiagnosticCodeBlock(props: {
loading: boolean;
diagnostic: string | null;
foregroundMutedColor: string;
}) {
if (props.loading && !props.diagnostic) {
return (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={props.foregroundMutedColor} />
<Text style={sheetStyles.mutedText}>Running diagnostic</Text>
</View>
);
}
if (props.diagnostic) {
return (
<ScrollView
style={sheetStyles.codeScroll}
contentContainerStyle={sheetStyles.codeContent}
showsVerticalScrollIndicator={false}
>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Text style={sheetStyles.codeText} selectable>
{props.diagnostic}
</Text>
</ScrollView>
</ScrollView>
);
}
return (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
</View>
);
}
export function ProviderDiagnosticSheet({
provider,
visible,
@@ -51,8 +110,9 @@ export function ProviderDiagnosticSheet({
}, [visible]);
const fetchedAtLabel = useMemo(() => {
if (!providerEntry?.fetchedAt) return null;
// clockTick is referenced so the label recomputes each timer tick.
void clockTick;
return formatTimeAgo(new Date(providerEntry.fetchedAt));
// clockTick triggers re-computation on timer
}, [providerEntry?.fetchedAt, clockTick]);
const q = query.trim().toLowerCase();
@@ -81,6 +141,15 @@ export function ProviderDiagnosticSheet({
[client, provider],
);
const refreshButtonStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
sheetStyles.iconButton,
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
refreshInFlight ? sheetStyles.disabled : null,
],
[refreshInFlight],
);
const handleRefresh = useCallback(() => {
if (!provider) {
return;
@@ -90,6 +159,35 @@ export function ProviderDiagnosticSheet({
});
}, [fetchDiagnostic, provider, refresh]);
const headerActions = useMemo(
() => (
<Pressable
onPress={handleRefresh}
disabled={refreshInFlight}
hitSlop={8}
style={refreshButtonStyle}
accessibilityRole="button"
accessibilityLabel={
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
}
>
{refreshInFlight ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
),
[
handleRefresh,
refreshInFlight,
refreshButtonStyle,
providerLabel,
theme.iconSize.sm,
theme.colors.foregroundMuted,
],
);
useEffect(() => {
if (visible) {
fetchDiagnostic();
@@ -132,14 +230,7 @@ export function ProviderDiagnosticSheet({
);
}
return filteredModels.map((model: AgentModelDefinition, index) => (
<View key={model.id} style={[sheetStyles.modelRow, index > 0 && sheetStyles.modelRowBorder]}>
<Text style={sheetStyles.modelLabel} numberOfLines={1}>
{model.label}
</Text>
<Text style={sheetStyles.modelId} numberOfLines={1} selectable>
{model.id}
</Text>
</View>
<ModelRow key={model.id} model={model} isFirst={index === 0} />
));
}
@@ -148,56 +239,18 @@ export function ProviderDiagnosticSheet({
title={providerLabel}
visible={visible}
onClose={onClose}
snapPoints={["50%", "85%"]}
snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS}
scrollable={false}
headerActions={
<Pressable
onPress={handleRefresh}
disabled={refreshInFlight}
hitSlop={8}
style={({ hovered, pressed }) => [
sheetStyles.iconButton,
(hovered || pressed) && sheetStyles.iconButtonHovered,
refreshInFlight ? sheetStyles.disabled : null,
]}
accessibilityRole="button"
accessibilityLabel={
refreshInFlight ? `Refreshing ${providerLabel}` : `Refresh ${providerLabel}`
}
>
{refreshInFlight ? (
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
) : (
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)}
</Pressable>
}
headerActions={headerActions}
>
<View style={sheetStyles.section}>
<Text style={sheetStyles.sectionTitle}>Diagnostic</Text>
<View style={sheetStyles.codeBlock}>
{loading && !diagnostic ? (
<View style={sheetStyles.codeBlockLoading}>
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
<Text style={sheetStyles.mutedText}>Running diagnostic</Text>
</View>
) : diagnostic ? (
<ScrollView
style={sheetStyles.codeScroll}
contentContainerStyle={sheetStyles.codeContent}
showsVerticalScrollIndicator={false}
>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<Text style={sheetStyles.codeText} selectable>
{diagnostic}
</Text>
</ScrollView>
</ScrollView>
) : (
<View style={sheetStyles.codeBlockLoading}>
<Text style={sheetStyles.mutedText}>No diagnostic available.</Text>
</View>
)}
<DiagnosticCodeBlock
loading={loading}
diagnostic={diagnostic}
foregroundMutedColor={theme.colors.foregroundMuted}
/>
</View>
</View>
@@ -225,7 +278,7 @@ export function ProviderDiagnosticSheet({
autoCapitalize="none"
autoCorrect={false}
// @ts-expect-error - outlineStyle is web-only
style={[sheetStyles.searchInput, isWeb && { outlineStyle: "none" }]}
style={DIAGNOSTIC_SEARCH_INPUT_STYLE}
/>
</View>
) : null}
@@ -363,3 +416,6 @@ const sheetStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
}));
const DIAGNOSTIC_SHEET_SNAP_POINTS = ["50%", "85%"];
const DIAGNOSTIC_SEARCH_INPUT_STYLE = [sheetStyles.searchInput, isWeb && { outlineStyle: "none" }];

View File

@@ -1,5 +1,12 @@
import { useState, useCallback } from "react";
import { View, Text, TextInput, Pressable, ActivityIndicator } from "react-native";
import { useState, useCallback, useMemo } from "react";
import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CircleHelp, X } from "lucide-react-native";
@@ -63,6 +70,126 @@ interface QuestionFormCardProps {
const IS_WEB = isWeb;
interface QuestionOptionRowProps {
qIndex: number;
optIndex: number;
option: QuestionOption;
isSelected: boolean;
multiSelect: boolean;
isResponding: boolean;
onToggle: (qIndex: number, optIndex: number, multiSelect: boolean) => void;
}
function QuestionOptionRow({
qIndex,
optIndex,
option,
isSelected,
multiSelect,
isResponding,
onToggle,
}: QuestionOptionRowProps) {
const { theme } = useUnistyles();
const handlePress = useCallback(() => {
onToggle(qIndex, optIndex, multiSelect);
}, [onToggle, qIndex, optIndex, multiSelect]);
const pressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.optionItem,
(Boolean(hovered) || isSelected) && {
backgroundColor: theme.colors.surface2,
},
pressed && styles.optionItemPressed,
],
[isSelected, theme.colors.surface2],
);
const optionLabelStyle = useMemo(
() => [styles.optionLabel, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const optionDescriptionStyle = useMemo(
() => [styles.optionDescription, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
return (
<Pressable style={pressableStyle} onPress={handlePress} disabled={isResponding}>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={optionLabelStyle}>{option.label}</Text>
{option.description ? (
<Text style={optionDescriptionStyle}>{option.description}</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
}
interface QuestionOtherInputProps {
qIndex: number;
value: string;
isResponding: boolean;
onChange: (qIndex: number, text: string) => void;
onSubmit: () => void;
}
function QuestionOtherInput({
qIndex,
value,
isResponding,
onChange,
onSubmit,
}: QuestionOtherInputProps) {
const { theme } = useUnistyles();
const handleChange = useCallback(
(text: string) => {
onChange(qIndex, text);
},
[onChange, qIndex],
);
const otherInputStyle = useMemo(
() =>
[
styles.otherInput,
{
borderColor: value.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
IS_WEB ? { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" } : null,
] as const,
[
value.length,
theme.colors.borderAccent,
theme.colors.border,
theme.colors.foreground,
theme.colors.surface2,
],
);
return (
<TextInput
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={handleChange}
onSubmitEditing={onSubmit}
editable={!isResponding}
blurOnSubmit={false}
/>
);
}
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
@@ -110,22 +237,19 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
}, []);
if (!questions) {
return null;
}
const allAnswered =
questions?.every((_, qIndex) => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
}) ?? false;
const allAnswered = questions.every((_, qIndex) => {
const selected = selections[qIndex];
const otherText = otherTexts[qIndex]?.trim();
return (selected && selected.size > 0) || (otherText && otherText.length > 0);
});
function handleSubmit() {
if (!allAnswered || isResponding) return;
const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return;
setRespondingAction("submit");
const answers: Record<string, string> = {};
for (let i = 0; i < questions!.length; i++) {
const q = questions![i];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
@@ -141,164 +265,146 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
behavior: "allow",
updatedInput: { ...permission.request.input, answers },
});
}
}, [
questions,
allAnswered,
isResponding,
selections,
otherTexts,
onRespond,
permission.request.input,
]);
function handleDeny() {
const handleDeny = useCallback(() => {
setRespondingAction("dismiss");
onRespond({
behavior: "deny",
message: "Dismissed by user",
});
}, [onRespond]);
const dismissButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
],
[theme.colors.surface2, theme.colors.surface1, theme.colors.borderAccent],
);
const submitDisabled = !allAnswered || isResponding;
const submitButtonStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.actionButton,
{
backgroundColor: hovered && !submitDisabled ? theme.colors.surface2 : theme.colors.surface1,
borderColor: submitDisabled ? theme.colors.border : theme.colors.borderAccent,
opacity: submitDisabled ? 0.5 : 1,
},
pressed && !submitDisabled ? styles.optionItemPressed : null,
],
[
submitDisabled,
theme.colors.surface2,
theme.colors.surface1,
theme.colors.border,
theme.colors.borderAccent,
],
);
const containerStyle = useMemo(
() => [
styles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
],
[theme.colors.surface1, theme.colors.border],
);
const questionTextStyle = useMemo(
() => [styles.questionText, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const actionsContainerStyle = useMemo(
() => [styles.actionsContainer, !isMobile && styles.actionsContainerDesktop],
[isMobile],
);
const dismissActionTextStyle = useMemo(
() => [styles.actionText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
);
const submitActionTextColor = allAnswered
? theme.colors.foreground
: theme.colors.foregroundMuted;
const submitActionTextStyle = useMemo(
() => [styles.actionText, { color: submitActionTextColor }],
[submitActionTextColor],
);
if (!questions) {
return null;
}
return (
<View
style={[
styles.container,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.border,
},
]}
>
<View style={containerStyle}>
{questions.map((q, qIndex) => {
const selected = selections[qIndex] ?? new Set<number>();
const otherText = otherTexts[qIndex] ?? "";
return (
<View key={qIndex} style={styles.questionBlock}>
<View key={q.question} style={styles.questionBlock}>
<View style={styles.questionHeader}>
<Text style={[styles.questionText, { color: theme.colors.foreground }]}>
{q.question}
</Text>
<Text style={questionTextStyle}>{q.question}</Text>
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
</View>
<View style={styles.optionsWrap}>
{q.options.map((opt, optIndex) => {
const isSelected = selected.has(optIndex);
return (
<Pressable
key={optIndex}
style={({ pressed, hovered = false }) => [
styles.optionItem,
(hovered || isSelected) && {
backgroundColor: theme.colors.surface2,
},
pressed && styles.optionItemPressed,
]}
onPress={() => toggleOption(qIndex, optIndex, q.multiSelect)}
disabled={isResponding}
>
<View style={styles.optionItemContent}>
<View style={styles.optionTextBlock}>
<Text style={[styles.optionLabel, { color: theme.colors.foreground }]}>
{opt.label}
</Text>
{opt.description ? (
<Text
style={[
styles.optionDescription,
{ color: theme.colors.foregroundMuted },
]}
>
{opt.description}
</Text>
) : null}
</View>
{isSelected ? (
<View style={styles.optionCheckSlot}>
<Check size={16} color={theme.colors.foregroundMuted} />
</View>
) : null}
</View>
</Pressable>
);
})}
{q.options.map((opt, optIndex) => (
<QuestionOptionRow
key={opt.label}
qIndex={qIndex}
optIndex={optIndex}
option={opt}
isSelected={selected.has(optIndex)}
multiSelect={q.multiSelect}
isResponding={isResponding}
onToggle={toggleOption}
/>
))}
</View>
<TextInput
style={[
styles.otherInput,
{
borderColor:
otherText.length > 0 ? theme.colors.borderAccent : theme.colors.border,
color: theme.colors.foreground,
backgroundColor: theme.colors.surface2,
},
// @ts-expect-error - outlineStyle is web-only
IS_WEB && { outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent" },
]}
placeholder="Other..."
placeholderTextColor={theme.colors.foregroundMuted}
<QuestionOtherInput
qIndex={qIndex}
value={otherText}
onChangeText={(text) => setOtherText(qIndex, text)}
onSubmitEditing={handleSubmit}
editable={!isResponding}
blurOnSubmit={false}
isResponding={isResponding}
onChange={setOtherText}
onSubmit={handleSubmit}
/>
</View>
);
})}
<View style={[styles.actionsContainer, !isMobile && styles.actionsContainerDesktop]}>
<Pressable
style={({ pressed, hovered = false }) => [
styles.actionButton,
{
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
pressed && styles.optionItemPressed,
]}
onPress={handleDeny}
disabled={isResponding}
>
<View style={actionsContainerStyle}>
<Pressable style={dismissButtonStyle} onPress={handleDeny} disabled={isResponding}>
{respondingAction === "dismiss" ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.actionContent}>
<X size={14} color={theme.colors.foregroundMuted} />
<Text style={[styles.actionText, { color: theme.colors.foregroundMuted }]}>
Dismiss
</Text>
<Text style={dismissActionTextStyle}>Dismiss</Text>
</View>
)}
</Pressable>
<Pressable
style={({ pressed, hovered = false }) => {
const disabled = !allAnswered || isResponding;
return [
styles.actionButton,
{
backgroundColor:
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,
];
}}
onPress={handleSubmit}
disabled={!allAnswered || isResponding}
>
<Pressable style={submitButtonStyle} onPress={handleSubmit} disabled={submitDisabled}>
{respondingAction === "submit" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<View style={styles.actionContent}>
<Check
size={14}
color={allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.actionText,
{
color: allAnswered ? theme.colors.foreground : theme.colors.foregroundMuted,
},
]}
>
Submit
</Text>
<Check size={14} color={submitActionTextColor} />
<Text style={submitActionTextStyle}>Submit</Text>
</View>
)}
</Pressable>

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { ActivityIndicator, Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, Square } from "lucide-react-native";
@@ -23,6 +24,19 @@ export function RealtimeVoiceOverlay({
}: RealtimeVoiceOverlayProps) {
const { theme } = useUnistyles();
const { volume, isSpeaking } = useVoiceTelemetry();
const muteButtonStyle = useMemo(
() => [
styles.actionButton,
styles.muteButton,
isMuted ? styles.muteButtonMuted : undefined,
isSwitching ? styles.buttonDisabled : undefined,
],
[isMuted, isSwitching],
);
const stopButtonStyle = useMemo(
() => [styles.actionButton, styles.stopButton, isSwitching ? styles.buttonDisabled : undefined],
[isSwitching],
);
return (
<View style={styles.container}>
<View style={styles.meterContainer}>
@@ -40,12 +54,7 @@ export function RealtimeVoiceOverlay({
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel={isMuted ? "Unmute realtime voice" : "Mute realtime voice"}
style={[
styles.actionButton,
styles.muteButton,
isMuted ? styles.muteButtonMuted : undefined,
isSwitching ? styles.buttonDisabled : undefined,
]}
style={muteButtonStyle}
>
{isMuted ? (
<MicOff size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
@@ -59,11 +68,7 @@ export function RealtimeVoiceOverlay({
disabled={isSwitching}
accessibilityRole="button"
accessibilityLabel="Stop realtime voice and interrupt turn"
style={[
styles.actionButton,
styles.stopButton,
isSwitching ? styles.buttonDisabled : undefined,
]}
style={stopButtonStyle}
>
{isSwitching ? (
<ActivityIndicator size="small" color={theme.colors.palette.white} />

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from "react";
import { View } from "react-native";
import { useCallback, useMemo, useRef, useState } from "react";
import { View, type PointerEvent as RNPointerEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
export interface ResizeHandleProps {
@@ -32,8 +32,8 @@ export function ResizeHandle({
const highlighted = active || dragging;
const handlePointerDown = useCallback(
(event: any) => {
const hitAreaElement = event.currentTarget as HTMLElement | null;
(event: RNPointerEvent) => {
const hitAreaElement = event.currentTarget as unknown as HTMLElement | null;
const containerElement = hitAreaElement?.parentElement?.parentElement ?? null;
if (!containerElement) {
return;
@@ -49,7 +49,8 @@ export function ResizeHandle({
pointerStateRef.current = {
containerSize,
pointerStart: direction === "horizontal" ? event.clientX : event.clientY,
pointerStart:
direction === "horizontal" ? event.nativeEvent.clientX : event.nativeEvent.clientY,
leftSize: sizes[index] ?? 0,
rightSize: sizes[index + 1] ?? 0,
};
@@ -93,47 +94,57 @@ export function ResizeHandle({
[direction, groupId, index, onResizeSplit, sizes],
);
const handlePointerEnter = useCallback(() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
}, 150);
}, []);
const handlePointerLeave = useCallback(() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setActive(false);
}, []);
const handleStyle = useMemo(
() => [
styles.handle,
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
{ backgroundColor: theme.colors.border },
],
[direction, theme.colors.border],
);
const highlightStyle = useMemo(
() => [
styles.highlight,
direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
],
[direction, theme.colors.accent],
);
const hitAreaStyle = useMemo(
() => [
styles.hitArea,
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
} as object,
],
[direction],
);
return (
<View
style={[
styles.handle,
direction === "horizontal" ? styles.handleHorizontal : styles.handleVertical,
{ backgroundColor: theme.colors.border },
]}
>
{highlighted && (
<View
pointerEvents="none"
style={[
styles.highlight,
direction === "horizontal" ? styles.highlightHorizontal : styles.highlightVertical,
{ backgroundColor: theme.colors.accent },
]}
/>
)}
<View style={handleStyle}>
{highlighted && <View pointerEvents="none" style={highlightStyle} />}
<View
role="separator"
aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"}
style={[
styles.hitArea,
direction === "horizontal" ? styles.hitAreaHorizontal : styles.hitAreaVertical,
{
cursor: direction === "horizontal" ? "col-resize" : "row-resize",
} as any,
]}
style={hitAreaStyle}
onPointerDown={handlePointerDown}
onPointerEnter={() => {
hoverTimerRef.current = setTimeout(() => {
setActive(true);
}, 150);
}}
onPointerLeave={() => {
if (hoverTimerRef.current) {
clearTimeout(hoverTimerRef.current);
hoverTimerRef.current = null;
}
setActive(false);
}}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
/>
</View>
);

Some files were not shown because too many files have changed in this diff Show More