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
co-authored by github-actions[bot] <github-actions[bot]@users.noreply.github.com>
parent 5b2f3fd830
commit 446dd9cfe6
605 changed files with 57451 additions and 43758 deletions
@@ -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} />;
+40 -16
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"}
+83 -62
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
@@ -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";
@@ -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 {
@@ -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 = {
+404 -243
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>;
}
@@ -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) {
@@ -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>;
@@ -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",
),
@@ -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>
@@ -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>
+34 -17
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>
);
@@ -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"
/>,
);
+27 -23
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>
+7 -7
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];
@@ -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}>
+270 -129
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,
},
@@ -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(() => {
+48 -43
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
@@ -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;
@@ -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();
@@ -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} />
</>
);
}
@@ -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];
+10 -10
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
@@ -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>
+89 -51
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>
+39 -29
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",
@@ -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}
/>
);
}
@@ -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}
/>,
);
@@ -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}
@@ -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)];
@@ -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
+42 -16
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"
/>
@@ -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 &&
@@ -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
@@ -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"
@@ -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 }) =>
@@ -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} />
@@ -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>
@@ -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>
);
@@ -12,5 +12,5 @@ export function HostRouteBootstrapBoundary({ children }: { children: ReactNode }
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
return <>{children}</>;
return children;
}
@@ -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" />;
}
@@ -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) => (
@@ -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 },
+234 -177
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>
@@ -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
@@ -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>
+164 -56
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 };
+72 -29
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}>
@@ -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,
},
@@ -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" }];
+241 -135
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>
@@ -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} />
+52 -41
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>
);
@@ -1,14 +1,56 @@
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
const SECTION_OPACITIES: readonly number[] = [1, 0.7, 0.4];
const SECTION_KEYS = SECTION_OPACITIES.map((_, i) => `skeleton-section-${i}`);
const ROW_KEYS_BY_SECTION: readonly (readonly string[])[] = SECTION_OPACITIES.map((_, sIdx) =>
[0, 1, 2].map((r) => `skeleton-row-${sIdx}-${r}`),
);
function SkeletonPulse({ pulse, style }: { pulse: Animated.Value; style: StyleProp<ViewStyle> }) {
const opacity = pulse.interpolate({
inputRange: [0, 1],
outputRange: [0.4, 0.8],
});
return <Animated.View style={[style, { opacity }]} />;
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
return <Animated.View style={pulseStyle} />;
}
function SkeletonSection({
pulse,
sectionOpacity,
sectionIdx,
}: {
pulse: Animated.Value;
sectionOpacity: number;
sectionIdx: number;
}) {
const sectionStyle = useMemo(
() => [styles.section, { opacity: sectionOpacity }],
[sectionOpacity],
);
return (
<View style={sectionStyle}>
<View style={styles.sectionHeader}>
<SkeletonPulse pulse={pulse} style={styles.chevron} />
<SkeletonPulse pulse={pulse} style={styles.projectIcon} />
<SkeletonPulse pulse={pulse} style={styles.sectionTitle} />
</View>
<View style={styles.rows}>
{ROW_KEYS_BY_SECTION[sectionIdx]?.map((key) => (
<View key={key} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
</View>
))}
</View>
</View>
);
}
export function SidebarAgentListSkeleton() {
@@ -36,27 +78,13 @@ export function SidebarAgentListSkeleton() {
return (
<View style={styles.container}>
{[1, 0.7, 0.4].map((sectionOpacity, sectionIdx) => (
<View
key={`skeleton-section-${sectionIdx}`}
style={[styles.section, { opacity: sectionOpacity }]}
>
<View style={styles.sectionHeader}>
<SkeletonPulse pulse={pulse} style={styles.chevron} />
<SkeletonPulse pulse={pulse} style={styles.projectIcon} />
<SkeletonPulse pulse={pulse} style={styles.sectionTitle} />
</View>
<View style={styles.rows}>
{Array.from({ length: 3 }).map((__, rowIdx) => (
<View key={`skeleton-row-${sectionIdx}-${rowIdx}`} style={styles.row}>
<SkeletonPulse pulse={pulse} style={styles.rowDot} />
<SkeletonPulse pulse={pulse} style={styles.rowTitle} />
<SkeletonPulse pulse={pulse} style={styles.rowBadge} />
</View>
))}
</View>
</View>
{SECTION_OPACITIES.map((sectionOpacity, sectionIdx) => (
<SkeletonSection
key={SECTION_KEYS[sectionIdx]}
pulse={pulse}
sectionOpacity={sectionOpacity}
sectionIdx={sectionIdx}
/>
))}
</View>
);
@@ -45,13 +45,13 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
const SERVER_ID = "sidebar-render-count";
type RenderCounts = {
interface RenderCounts {
frame: number;
headers: Record<string, number>;
rows: Record<string, number>;
projectSelection: Record<string, number>;
rowSelection: Record<string, number>;
};
}
const runningScript: WorkspaceScriptPayload = {
scriptName: "web",
@@ -203,11 +203,11 @@ function WorkspaceRowProbe({
workspaceId: string;
counts: RenderCounts;
}): null {
const workspace = useWorkspaceFields(serverId, workspaceId, (entry) =>
const workspaceEntry = useWorkspaceFields(serverId, workspaceId, (entry) =>
createSidebarWorkspaceEntry({ serverId, workspace: entry }),
);
if (workspace) {
incrementRecord(counts.rows, workspace.workspaceId);
if (workspaceEntry) {
incrementRecord(counts.rows, workspaceEntry.workspaceId);
}
return null;
}
@@ -223,7 +223,7 @@ function ProjectActiveProbe({
}): null {
useIsNavigationProjectActive({
serverId,
workspaceIds: project.workspaces.map((workspace) => workspace.workspaceId),
workspaceIds: project.workspaces.map((entry) => entry.workspaceId),
});
incrementRecord(counts.projectSelection, project.projectKey);
return null;
@@ -253,16 +253,16 @@ function SidebarFrameProbe({ counts }: { counts: RenderCounts }): ReactElement {
<div key={project.projectKey}>
<ProjectHeaderProbe project={project} counts={counts} />
<ProjectActiveProbe serverId={SERVER_ID} project={project} counts={counts} />
{project.workspaces.map((workspace) => (
<React.Fragment key={workspace.workspaceKey}>
{project.workspaces.map((entry) => (
<React.Fragment key={entry.workspaceKey}>
<WorkspaceRowProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
serverId={entry.serverId}
workspaceId={entry.workspaceId}
counts={counts}
/>
<WorkspaceSelectionProbe
serverId={workspace.serverId}
workspaceId={workspace.workspaceId}
serverId={entry.serverId}
workspaceId={entry.workspaceId}
counts={counts}
/>
</React.Fragment>
@@ -397,13 +397,14 @@ describe("sidebar workspace render isolation", () => {
};
({ root, container } = await renderProbe(counts));
const applyRunningScript = (current: Parameters<typeof patchWorkspaceScripts>[0]) =>
patchWorkspaceScripts(current, {
workspaceId: "a-main",
scripts: [{ ...runningScript }],
});
act(() => {
useSessionStore.getState().setWorkspaces(SERVER_ID, (current) =>
patchWorkspaceScripts(current, {
workspaceId: "a-main",
scripts: [{ ...runningScript }],
}),
);
useSessionStore.getState().setWorkspaces(SERVER_ID, applyRunningScript);
});
expect(counts).toEqual({
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import { Pressable, Text, View } from "react-native";
import { useCallback, useMemo } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { LucideIcon } from "lucide-react-native";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
@@ -30,6 +31,35 @@ export function SidebarHeaderRow({
}: SidebarHeaderRowProps) {
const { theme } = useUnistyles();
const buttonStyle = useCallback(
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.button,
(Boolean(hovered) || isActive) && styles.buttonHovered,
],
[isActive],
);
const renderChildren = useCallback(
(state: PressableStateCallbackType & { hovered?: boolean }) => {
const isHighlighted = Boolean(state.hovered) || isActive;
const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Icon size={theme.iconSize.md} color={iconColor} />
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
</>
);
},
[
Icon,
isActive,
label,
theme.colors.foreground,
theme.colors.foregroundMuted,
theme.iconSize.md,
],
);
return (
<View style={styles.container}>
<Pressable
@@ -39,23 +69,28 @@ export function SidebarHeaderRow({
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
style={({ hovered }) => [styles.button, (hovered || isActive) && styles.buttonHovered]}
style={buttonStyle}
>
{({ hovered }) => {
const isHighlighted = hovered || isActive;
const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Icon size={theme.iconSize.md} color={iconColor} />
<Text style={[styles.label, isHighlighted && styles.labelHighlighted]}>{label}</Text>
</>
);
}}
{renderChildren}
</Pressable>
</View>
);
}
function SidebarHeaderRowLabel({
label,
isHighlighted,
}: {
label: string;
isHighlighted: boolean;
}) {
const labelStyle = useMemo(
() => [styles.label, isHighlighted && styles.labelHighlighted],
[isHighlighted],
);
return <Text style={labelStyle}>{label}</Text>;
}
const styles = StyleSheet.create((theme) => ({
container: {
height: {
@@ -7,10 +7,10 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SortableInlineList } from "./sortable-inline-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);
@@ -73,18 +73,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(
<SortableInlineList
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}
/>,
);
});
@@ -1,4 +1,4 @@
import { useCallback, useState, type ReactElement } from "react";
import { useCallback, useMemo, useState, type ReactElement } from "react";
import {
DndContext,
closestCenter,
@@ -25,6 +25,13 @@ const restrictToHorizontalAxis: Modifier = ({ transform }) => ({
y: 0,
});
const DND_MODIFIERS: Modifier[] = [restrictToHorizontalAxis];
function computeDragOpacity(hasExternalContext: boolean, isDragging: boolean): number {
if (!isDragging) return 1;
return hasExternalContext ? 0.3 : 0.9;
}
function SortableItem<T>({
id,
item,
@@ -71,12 +78,15 @@ function SortableItem<T>({
const scaleTransform = !externalDndContext && isDragging ? "scale(1.01)" : "";
const combinedTransform = [baseTransform, scaleTransform].filter(Boolean).join(" ");
const style = {
transform: combinedTransform || undefined,
transition,
opacity: externalDndContext && isDragging ? 0.3 : isDragging ? 0.9 : 1,
zIndex: isDragging ? 1000 : 1,
};
const style = useMemo(
() => ({
transform: combinedTransform || undefined,
transition,
opacity: computeDragOpacity(Boolean(externalDndContext), isDragging),
zIndex: isDragging ? 1000 : 1,
}),
[combinedTransform, transition, externalDndContext, isDragging],
);
const info: DraggableRenderItemInfo<T> = {
item,
@@ -183,7 +193,10 @@ export function SortableInlineList<T>({
[clearDragState, disabled, items, keyExtractor, onDragEnd],
);
const ids = items.map((item, index) => keyExtractor(item, index));
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
);
const renderedItems = (
<SortableContext items={ids} strategy={horizontalListSortingStrategy}>
@@ -215,7 +228,7 @@ export function SortableInlineList<T>({
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
modifiers={DND_MODIFIERS}
onDragStart={handleDragStart}
onDragCancel={clearDragState}
onDragEnd={handleDragEnd}
+256 -179
View File
@@ -123,7 +123,7 @@ interface SplitPaneDropData {
paneId: string;
}
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout"> {
interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMoveTabToPane"> {
node: SplitNode;
uiTabs: WorkspaceTab[];
focusedPaneId: string;
@@ -141,7 +141,6 @@ interface SplitPaneViewProps extends Omit<
| "activeDragTabId"
| "showDropZones"
| "dropPreview"
| "onMoveTabToPane"
| "onResizeSplit"
> {
pane: SplitPane;
@@ -182,8 +181,13 @@ const MountedTabSlot = memo(function MountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
const wrapperStyle = useMemo(
() => ({ display: (isVisible ? "flex" : "none") as "flex" | "none", flex: 1 }),
[isVisible],
);
return (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
@@ -219,6 +223,82 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
return tabDescriptorMap;
}
interface DragMoveRects {
translatedRect: { left: number; top: number; width: number; height: number };
overRect: { left: number; top: number; width: number; height: number };
}
function resolveDragMoveRects(
event: Pick<DragMoveEvent, "active" | "over"> | Pick<DragOverEvent, "active" | "over">,
): DragMoveRects | null {
const translatedRect = event.active.rect.current.translated;
const overRect = event.over?.rect;
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
return null;
}
return { translatedRect, overRect };
}
function computeTabOverDropPreview(input: {
activeData: WorkspaceTabDragData;
overData: WorkspaceTabDragData;
rects: DragMoveRects;
panesById: Map<string, SplitPane>;
uiTabs: WorkspaceTab[];
}): TabDropPreview | null {
const { activeData, overData, rects, panesById, uiTabs } = input;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!targetPane) {
return null;
}
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
return computeTabDropPreview({
activePaneId: activeData.paneId,
activeTabId: activeData.tabId,
overPaneId: overData.paneId,
overTabId: overData.tabId,
targetTabs,
activeRect: {
left: rects.translatedRect.left,
width: rects.translatedRect.width,
},
overRect: {
left: rects.overRect.left,
width: rects.overRect.width,
},
});
}
function computePaneOverDropPreview(input: {
overData: SplitPaneDropData;
rects: DragMoveRects;
}): SplitDropZoneHover | null {
const { overData, rects } = input;
const centerX = rects.translatedRect.left + rects.translatedRect.width / 2;
const centerY = rects.translatedRect.top + rects.translatedRect.height / 2;
const relativeX = centerX - rects.overRect.left;
const relativeY = centerY - rects.overRect.top;
if (
Number.isNaN(relativeX) ||
Number.isNaN(relativeY) ||
relativeX < 0 ||
relativeX > rects.overRect.width ||
relativeY < 0 ||
relativeY > rects.overRect.height
) {
return null;
}
return {
paneId: overData.paneId,
position: resolveSplitDropPosition({
width: rects.overRect.width,
height: rects.overRect.height,
x: relativeX,
y: relativeY,
}),
};
}
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
@@ -328,44 +408,23 @@ export function SplitContainer({
return;
}
const translatedRect = event.active.rect.current.translated;
const overRect = event.over?.rect;
if (!translatedRect || !overRect || overRect.width <= 0 || overRect.height <= 0) {
const rects = resolveDragMoveRects(event);
if (!rects) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const targetPane = panesById.get(overData.paneId) ?? null;
if (!targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
const targetTabs = getWorkspacePaneDescriptors({
pane: targetPane,
tabs: uiTabs,
const preview = computeTabOverDropPreview({
activeData,
overData,
rects,
panesById,
uiTabs,
});
setDropPreview(null);
setTabDropPreview(
computeTabDropPreview({
activePaneId: activeData.paneId,
activeTabId: activeData.tabId,
overPaneId: overData.paneId,
overTabId: overData.tabId,
targetTabs,
activeRect: {
left: translatedRect.left,
width: translatedRect.width,
},
overRect: {
left: overRect.left,
width: overRect.width,
},
}),
);
setTabDropPreview(preview);
return;
}
@@ -375,33 +434,71 @@ export function SplitContainer({
return;
}
const centerX = translatedRect.left + translatedRect.width / 2;
const centerY = translatedRect.top + translatedRect.height / 2;
const relativeX = centerX - overRect.left;
const relativeY = centerY - overRect.top;
if (
Number.isNaN(relativeX) ||
Number.isNaN(relativeY) ||
relativeX < 0 ||
relativeX > overRect.width ||
relativeY < 0 ||
relativeY > overRect.height
) {
setDropPreview(null);
setDropPreview(computePaneOverDropPreview({ overData, rects }));
},
[panesById, uiTabs],
);
const applyTabDropEnd = useCallback(
(input: { activeData: WorkspaceTabDragData; overData: WorkspaceTabDragData }): void => {
const { activeData, overData } = input;
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
return;
}
setDropPreview({
paneId: overData.paneId,
position: resolveSplitDropPosition({
width: overRect.width,
height: overRect.height,
x: relativeX,
y: relativeY,
}),
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const resolvedTabDropPreview =
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
if (sourceIndex < 0 || !resolvedTabDropPreview) {
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(
sourceTabs,
sourceIndex,
resolvedTabDropPreview.insertionIndex,
);
onReorderTabsInPane(
activeData.paneId,
nextTabs.map((tab) => tab.tabId),
);
}
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
},
[onMoveTabToPane, onReorderTabsInPane, panesById, tabDropPreview, uiTabs],
);
const applyPaneDropEnd = useCallback(
(input: { activeData: WorkspaceTabDragData; overData: SplitPaneDropData }): void => {
const { activeData, overData } = input;
if (dropPreview?.paneId !== overData.paneId) {
return;
}
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
},
[panesById, uiTabs],
[dropPreview, onMoveTabToPane, onSplitPane],
);
const handleDragEnd = useCallback(
@@ -414,87 +511,18 @@ export function SplitContainer({
setActiveDragTabId(null);
if (activeData?.kind !== "workspace-tab" || !event.over) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "workspace-tab") {
const sourcePane = panesById.get(activeData.paneId) ?? null;
const targetPane = panesById.get(overData.paneId) ?? null;
if (!sourcePane || !targetPane) {
setDropPreview(null);
setTabDropPreview(null);
return;
if (activeData?.kind === "workspace-tab" && event.over) {
if (overData?.kind === "workspace-tab") {
applyTabDropEnd({ activeData, overData });
} else if (overData?.kind === "split-pane-drop") {
applyPaneDropEnd({ activeData, overData });
}
const sourceTabs = getWorkspacePaneDescriptors({ pane: sourcePane, tabs: uiTabs });
const targetTabs = getWorkspacePaneDescriptors({ pane: targetPane, tabs: uiTabs });
const sourceIndex = sourceTabs.findIndex((tab) => tab.tabId === activeData.tabId);
const resolvedTabDropPreview =
tabDropPreview?.paneId === overData.paneId ? tabDropPreview : null;
if (sourceIndex < 0 || !resolvedTabDropPreview) {
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (activeData.paneId === overData.paneId) {
if (sourceIndex !== resolvedTabDropPreview.insertionIndex) {
const nextTabs = arrayMove(
sourceTabs,
sourceIndex,
resolvedTabDropPreview.insertionIndex,
);
onReorderTabsInPane(
activeData.paneId,
nextTabs.map((tab) => tab.tabId),
);
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
const nextTargetTabIds = targetTabs.map((tab) => tab.tabId);
nextTargetTabIds.splice(resolvedTabDropPreview.insertionIndex, 0, activeData.tabId);
onMoveTabToPane(activeData.tabId, overData.paneId);
onReorderTabsInPane(overData.paneId, nextTargetTabIds);
setDropPreview(null);
setTabDropPreview(null);
return;
}
if (overData?.kind === "split-pane-drop" && dropPreview?.paneId === overData.paneId) {
if (dropPreview.position === "center") {
if (activeData.paneId !== overData.paneId) {
onMoveTabToPane(activeData.tabId, overData.paneId);
}
setDropPreview(null);
setTabDropPreview(null);
return;
}
onSplitPane({
tabId: activeData.tabId,
targetPaneId: overData.paneId,
position: dropPreview.position,
});
}
setDropPreview(null);
setTabDropPreview(null);
},
[
dropPreview,
onMoveTabToPane,
onReorderTabsInPane,
onSplitPane,
panesById,
tabDropPreview,
uiTabs,
],
[applyTabDropEnd, applyPaneDropEnd],
);
return (
@@ -533,7 +561,6 @@ export function SplitContainer({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -568,15 +595,21 @@ function DragOverlayTabChip({
normalizedWorkspaceId: string;
}) {
const tab = uiTabs.find((t) => t.tabId === tabId);
if (!tab) {
const descriptor = useMemo<WorkspaceTabDescriptor | null>(
() =>
tab
? {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
}
: null,
[tab],
);
if (!descriptor) {
return null;
}
const descriptor: WorkspaceTabDescriptor = {
key: tab.tabId,
tabId: tab.tabId,
kind: tab.target.kind,
target: tab.target,
};
return (
<DragOverlayTabChipInner
tab={descriptor}
@@ -597,6 +630,21 @@ function DragOverlayTabChipInner({
}) {
const { theme } = useUnistyles();
const chipStyle = useMemo(
() => [
styles.dragOverlayChip,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
],
[theme.colors.surface1, theme.colors.borderAccent],
);
const chipLabelStyle = useMemo(
() => [styles.dragOverlayLabel, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
return (
<WorkspaceTabPresentationResolver
tab={tab}
@@ -607,20 +655,9 @@ function DragOverlayTabChipInner({
const label = presentation.titleState === "loading" ? "Loading..." : presentation.label;
return (
<View
style={[
styles.dragOverlayChip,
{
backgroundColor: theme.colors.surface1,
borderColor: theme.colors.borderAccent,
},
]}
>
<View style={chipStyle}>
<WorkspaceTabIcon presentation={presentation} active size={14} />
<Text
numberOfLines={1}
style={[styles.dragOverlayLabel, { color: theme.colors.foreground }]}
>
<Text numberOfLines={1} style={chipLabelStyle}>
{label}
</Text>
</View>
@@ -630,6 +667,11 @@ function DragOverlayTabChipInner({
);
}
function SplitGroupChild({ flex, children }: { flex: number; children: ReactNode }) {
const childStyle = useMemo(() => [styles.groupChild, { flex }], [flex]);
return <View style={childStyle}>{children}</View>;
}
function SplitNodeView({
node,
workspaceKey,
@@ -656,7 +698,6 @@ function SplitNodeView({
onFocusPane,
onSplitPane,
onSplitPaneEmpty,
onMoveTabToPane,
onResizeSplit,
onReorderTabsInPane,
renderPaneEmptyState,
@@ -665,6 +706,21 @@ function SplitNodeView({
dropPreview,
tabDropPreview,
}: SplitNodeViewProps) {
const groupId = node.kind === "group" ? node.group.id : null;
const groupDirection = node.kind === "group" ? node.group.direction : null;
const storedGroupSizes = useWorkspaceLayoutStore((state) =>
groupId ? state.splitSizesByWorkspace[workspaceKey]?.[groupId] : undefined,
);
const groupStyle = useMemo(
() => [
styles.group,
groupDirection === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
],
[groupDirection],
);
if (node.kind === "pane") {
return (
<SplitPaneView
@@ -702,21 +758,13 @@ function SplitNodeView({
);
}
const groupSizes =
useWorkspaceLayoutStore(
(state) => state.splitSizesByWorkspace[workspaceKey]?.[node.group.id],
) ?? node.group.sizes;
const groupSizes = storedGroupSizes ?? node.group.sizes;
return (
<View
style={[
styles.group,
node.group.direction === "horizontal" ? styles.groupHorizontal : styles.groupVertical,
]}
>
<View style={groupStyle}>
{node.group.children.map((child, index) => (
<Fragment key={getNodeKey(child)}>
<View style={[styles.groupChild, { flex: groupSizes[index] ?? 1 }]}>
<SplitGroupChild flex={groupSizes[index] ?? 1}>
<SplitNodeView
node={child}
workspaceKey={workspaceKey}
@@ -743,7 +791,6 @@ function SplitNodeView({
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
onSplitPaneEmpty={onSplitPaneEmpty}
onMoveTabToPane={onMoveTabToPane}
onResizeSplit={onResizeSplit}
onReorderTabsInPane={onReorderTabsInPane}
renderPaneEmptyState={renderPaneEmptyState}
@@ -752,7 +799,7 @@ function SplitNodeView({
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
/>
</View>
</SplitGroupChild>
{index < node.group.children.length - 1 ? (
<ResizeHandle
direction={node.group.direction}
@@ -791,7 +838,7 @@ function SplitPaneView({
onCreateTerminalTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
onSplitPane: _onSplitPane,
onSplitPaneEmpty,
onReorderTabsInPane,
renderPaneEmptyState,
@@ -800,7 +847,7 @@ function SplitPaneView({
dropPreview,
tabDropPreview,
}: SplitPaneViewProps) {
const { theme } = useUnistyles();
const { theme: _theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
const stableOnFocusPane = useStableEvent(onFocusPane);
const padding = useWindowControlsPadding("tabRow");
@@ -873,9 +920,44 @@ function SplitPaneView({
};
}, [stableOnFocusPane, pane.id]);
const paneId = pane.id;
const handleCloseTabsToLeft = useCallback(
(tabId: string) => onCloseTabsToLeft(tabId, paneTabs),
[onCloseTabsToLeft, paneTabs],
);
const handleCloseTabsToRight = useCallback(
(tabId: string) => onCloseTabsToRight(tabId, paneTabs),
[onCloseTabsToRight, paneTabs],
);
const handleCloseOtherTabs = useCallback(
(tabId: string) => onCloseOtherTabs(tabId, paneTabs),
[onCloseOtherTabs, paneTabs],
);
const handleReorderTabs = useCallback(
(nextTabs: WorkspaceTabDescriptor[]) => {
onReorderTabsInPane(
paneId,
nextTabs.map((tab) => tab.tabId),
);
},
[onReorderTabsInPane, paneId],
);
const handleSplitRight = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "right" }),
[onSplitPaneEmpty, paneId],
);
const handleSplitDown = useCallback(
() => onSplitPaneEmpty({ targetPaneId: paneId, position: "bottom" }),
[onSplitPaneEmpty, paneId],
);
const paneTabsStyle = useMemo(
() => [styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }],
[padding.left, padding.right],
);
return (
<View ref={paneRef} collapsable={false} style={styles.pane}>
<View style={[styles.paneTabs, { paddingLeft: padding.left, paddingRight: padding.right }]}>
<View style={paneTabsStyle}>
<TitlebarDragRegion />
<WorkspaceDesktopTabsRow
paneId={pane.id}
@@ -890,19 +972,14 @@ function SplitPaneView({
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onReloadAgent={onReloadAgent}
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onCloseTabsToLeft={handleCloseTabsToLeft}
onCloseTabsToRight={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(
pane.id,
nextTabs.map((tab) => tab.tabId),
);
}}
onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })}
onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })}
onReorderTabs={handleReorderTabs}
onSplitRight={handleSplitRight}
onSplitDown={handleSplitDown}
externalDndContext
activeDragTabId={activeDragTabId}
tabDropPreviewIndex={
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, type Ref } from "react";
import { useDroppable } from "@dnd-kit/core";
import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -62,7 +62,7 @@ export function SplitDropZone({ paneId, active, preview }: SplitDropZoneProps) {
}
return (
<View ref={setNodeRef as any} style={styles.overlay} pointerEvents="none">
<View ref={setNodeRef as unknown as Ref<View>} style={styles.overlay} pointerEvents="none">
{previewStyles ? (
<>
<View pointerEvents="none" style={previewStyles.overlay} />
@@ -1,4 +1,12 @@
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Fragment,
type ReactElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
FlatList,
Keyboard,
@@ -22,6 +30,10 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
autoscrollToTopThreshold: 0,
});
function keyExtractor(item: { id: string }): string {
return item.id;
}
function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrategy }) {
const {
agentId,
@@ -267,10 +279,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
});
const renderItem = useStableEvent(({ item, index }: ListRenderItemInfo<StreamItem>) => {
const rendered = renderHistoryMountedRow(item, index, historyRows);
return rendered ? <Fragment>{rendered}</Fragment> : null;
});
const renderItem = useStableEvent(
({ item, index }: ListRenderItemInfo<StreamItem>): ReactElement | null => {
const rendered = renderHistoryMountedRow(item, index, historyRows);
return (rendered ?? null) as ReactElement | null;
},
);
const liveHeaderContent = useMemo(() => {
const liveHeadRows = segments.liveHead.map((item, index) => (
@@ -283,7 +297,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory
) {
return listEmptyComponent ? <Fragment>{listEmptyComponent}</Fragment> : null;
return (listEmptyComponent ?? null) as ReactElement | null;
}
return (
<Fragment>
@@ -298,7 +312,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
ref={flatListRef}
data={historyRows}
renderItem={renderItem}
keyExtractor={(item) => item.id}
keyExtractor={keyExtractor}
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
@@ -18,11 +18,13 @@ function userMessage(index: number): StreamItem {
};
}
const VIRTUAL_ROW_STYLE = { height: 24 };
function createRenderers(onRowRender: () => void): StreamSegmentRenderers {
return {
renderHistoryVirtualizedRow: (item) => {
onRowRender();
return <div style={{ height: 24 }}>{item.id}</div>;
return <div style={VIRTUAL_ROW_STYLE}>{item.id}</div>;
},
renderHistoryMountedRow: (item) => <div>{item.id}</div>,
renderLiveHeadRow: (item) => <div>{item.id}</div>,
@@ -13,9 +13,9 @@ import { estimateStreamItemHeight } from "./agent-stream-web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
import { createStreamStrategy } from "./stream-strategy";
type CreateWebStreamStrategyInput = {
interface CreateWebStreamStrategyInput {
isMobileBreakpoint: boolean;
};
}
type ScrollBehaviorLike = "auto" | "smooth";
@@ -107,6 +107,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
} = props;
const scrollContainerRef = useRef<HTMLElement | null>(null);
const contentRef = useRef<HTMLElement | null>(null);
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
scrollContainerRef.current = node;
}, []);
const handleContentRef = useCallback((node: HTMLElement | null) => {
contentRef.current = node;
}, []);
const [followOutput, setFollowOutputr] = useState(true);
const setFollowOutput = (value: boolean) => {
setFollowOutputr(value);
@@ -178,7 +184,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return () => {
rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
};
}, [rowVirtualizer]);
}, [rowVirtualizer, props.agentId]);
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualTotalSize = rowVirtualizer.getTotalSize();
@@ -361,7 +367,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
: null,
});
updateScrollMetrics();
}, [cancelPendingStickToBottom, updateScrollMetrics]);
}, [cancelPendingStickToBottom, updateScrollMetrics, props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
@@ -705,19 +711,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
return (
<>
<div
ref={(node) => {
scrollContainerRef.current = node;
}}
ref={handleScrollContainerRef}
data-testid="agent-chat-scroll"
id={`agent-chat-scroll-${shouldUseVirtualizer ? "web-dom-virtualized" : "web-dom-scroll"}`}
style={scrollContainerStyle}
>
<div
ref={(node) => {
contentRef.current = node;
}}
style={contentContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
@@ -744,7 +743,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
) : null}
{mountedHistoryRows}
{boundary.hasMountedHistory && boundary.hasLiveHead && boundary.historyToHeadGap > 0 ? (
<div style={{ height: boundary.historyToHeadGap, width: "100%" }} />
<HistoryToHeadSpacer height={boundary.historyToHeadGap} />
) : null}
{liveHeadRows}
{liveAuxiliary}
@@ -790,3 +789,12 @@ export function createWebStreamStrategy(input: CreateWebStreamStrategyInput): St
getBottomOffset: (metrics) => Math.max(0, metrics.contentHeight - metrics.viewportHeight),
});
}
interface HistoryToHeadSpacerProps {
height: number;
}
function HistoryToHeadSpacer({ height }: HistoryToHeadSpacerProps) {
const spacerStyle = useMemo(() => ({ height, width: "100%" as const }), [height]);
return <div style={spacerStyle} />;
}
+18 -18
View File
@@ -21,36 +21,36 @@ export type BottomAnchorTransportBehavior = Readonly<{
verificationRetryMode: "rescroll" | "recheck";
}>;
export type StreamViewportMetrics = {
export interface StreamViewportMetrics {
contentHeight: number;
viewportHeight: number;
};
}
export type StreamNearBottomInput = StreamViewportMetrics & {
offsetY: number;
threshold: number;
};
export type StreamEdgeSlotProps = {
ListHeaderComponent?: ReactElement | ComponentType<any> | null;
export interface StreamEdgeSlotProps {
ListHeaderComponent?: ReactElement | ComponentType<unknown> | null;
ListHeaderComponentStyle?: StyleProp<ViewStyle>;
ListFooterComponent?: ReactElement | ComponentType<any> | null;
ListFooterComponent?: ReactElement | ComponentType<unknown> | null;
ListFooterComponentStyle?: StyleProp<ViewStyle>;
};
}
export type StreamViewportHandle = {
export interface StreamViewportHandle {
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
prepareForViewportChange: () => void;
};
}
export type StreamSegmentRenderers = {
export interface StreamSegmentRenderers {
renderHistoryVirtualizedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderHistoryMountedRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveHeadRow: (item: StreamItem, index: number, items: StreamItem[]) => ReactNode;
renderLiveAuxiliary: () => ReactNode;
};
}
export type StreamRenderInput = {
export interface StreamRenderInput {
agentId: string;
segments: StreamRenderSegments;
boundary: StreamHistoryBoundary;
@@ -64,12 +64,12 @@ export type StreamRenderInput = {
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
forwardListContentContainerStyle: StyleProp<ViewStyle>;
};
}
export type ResolveStreamRenderStrategyInput = {
export interface ResolveStreamRenderStrategyInput {
platform: string;
isMobileBreakpoint: boolean;
};
}
export interface StreamStrategy {
render: (input: StreamRenderInput) => ReactNode;
@@ -85,7 +85,7 @@ export interface StreamStrategy {
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
getEdgeSlotProps: (
component: ReactElement | ComponentType<any> | null,
component: ReactElement | ComponentType<unknown> | null,
gapSize: number,
) => StreamEdgeSlotProps;
getMaintainVisibleContentPosition: () => MaintainVisibleContentPositionConfig | undefined;
@@ -98,7 +98,7 @@ export interface StreamStrategy {
shouldUseVirtualizedList: () => boolean;
}
type StreamStrategyConfig = {
interface StreamStrategyConfig {
render: StreamStrategy["render"];
orderTailReverse: boolean;
orderHeadReverse: boolean;
@@ -114,7 +114,7 @@ type StreamStrategyConfig = {
useVirtualizedList: boolean;
isNearBottom: (input: StreamNearBottomInput) => boolean;
getBottomOffset: (metrics: StreamViewportMetrics) => number;
};
}
const NATIVE_SETTLING_VERIFICATION_DELAY_FRAMES = 4;
@@ -262,7 +262,7 @@ export function getBottomOffsetForStreamRenderStrategy(
export function getStreamEdgeSlotProps(params: {
strategy: StreamStrategy;
component: ReactElement | ComponentType<any> | null;
component: ReactElement | ComponentType<unknown> | null;
gapSize: number;
}): StreamEdgeSlotProps {
return params.strategy.getEdgeSlotProps(params.component, params.gapSize);
+40 -41
View File
@@ -7,16 +7,16 @@ import Animated, {
withRepeat,
withTiming,
} from "react-native-reanimated";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
const SYNCED_LOADER_DURATION_MS = 950;
const SYNCED_LOADER_EPOCH_MS = 0;
const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
const DOT_COUNT = DOT_SEQUENCE.length;
const GRID_ROWS = 3;
const GRID_COLUMNS = 2;
const SNAKE_SEGMENT_OFFSETS = [0, -1, -2, -3, -4] as const;
const SNAKE_OPACITIES = [1, 0.78, 0.56, 0.34, 0] as const;
const DOT_KEYS = Array.from({ length: DOT_COUNT }, (_, i) => `dot-${i}`);
const sharedStepProgress = makeMutable(0);
let sharedLoopStarted = false;
@@ -66,24 +66,25 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
const gridWidth = dotSize * 2 + gap;
const gridHeight = dotSize * 3 + gap * 2;
return (
<View
style={{
const gridStyle = useMemo(
() => [animatedStyle, { width: gridWidth, height: gridHeight }],
[animatedStyle, gridWidth, gridHeight],
);
const containerStyle = useMemo(
() =>
({
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
}}
>
<Animated.View
style={[
animatedStyle,
{
width: gridWidth,
height: gridHeight,
},
]}
>
}) as const,
[size],
);
return (
<View style={containerStyle}>
<Animated.View style={gridStyle}>
{Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
const rowIndex = Math.floor(dotIndex / GRID_COLUMNS);
const columnIndex = dotIndex % GRID_COLUMNS;
@@ -91,16 +92,13 @@ export function SyncedLoader({ size = 10, color }: { size?: number; color: strin
return (
<SpinnerDot
key={dotIndex}
key={DOT_KEYS[dotIndex]}
color={color}
dotSize={dotSize}
sequenceIndex={sequenceIndex}
progress={sharedStepProgress}
style={{
position: "absolute",
left: columnIndex * (dotSize + gap),
top: rowIndex * (dotSize + gap),
}}
left={columnIndex * (dotSize + gap)}
top={rowIndex * (dotSize + gap)}
/>
);
})}
@@ -114,17 +112,15 @@ function SpinnerDot({
dotSize,
sequenceIndex,
progress,
style,
left,
top,
}: {
color: string;
dotSize: number;
sequenceIndex: number;
progress: SharedValue<number>;
style: {
position: "absolute";
left: number;
top: number;
};
left: number;
top: number;
}) {
const animatedStyle = useAnimatedStyle(() => {
const headIndex = Math.floor(progress.value) % DOT_COUNT;
@@ -144,18 +140,21 @@ function SpinnerDot({
};
});
return (
<Animated.View
style={[
animatedStyle,
{
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
},
style,
]}
/>
const dotStyle = useMemo(
() => [
animatedStyle,
{
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: color,
position: "absolute" as const,
left,
top,
},
],
[animatedStyle, dotSize, color, left, top],
);
return <Animated.View style={dotStyle} />;
}
+171 -118
View File
@@ -1,6 +1,16 @@
"use dom";
import { useEffect, useMemo, useRef, useState, type Ref } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type Ref,
} from "react";
import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
@@ -35,11 +45,38 @@ const SCROLLBAR_HANDLE_SCROLL_VISIBILITY_MS = 1_200;
const SCROLLBAR_HANDLE_SCROLL_ACTIVE_MS = 110;
const WEBKIT_SCROLLBAR_STYLE_ID = "terminal-emulator-webkit-scrollbar-style";
type ViewportMetrics = {
const HOST_DIV_STYLE: CSSProperties = {
flex: 1,
minHeight: 0,
minWidth: 0,
width: "100%",
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
};
const SCROLLBAR_CONTAINER_STYLE: CSSProperties = {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 12,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
zIndex: 10,
pointerEvents: "none",
};
interface ViewportMetrics {
offset: number;
viewportSize: number;
contentSize: number;
};
}
function buildXtermThemeKey(theme: ITheme): string {
const values: Array<string> = [
@@ -159,6 +196,24 @@ export default function TerminalEmulator({
const scrollActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastObservedOffsetRef = useRef<number | null>(null);
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
const xtermThemeRef = useRef(xtermTheme);
xtermThemeRef.current = xtermTheme;
const mountCallbacksRef = useRef({
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
});
mountCallbacksRef.current = {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
const [viewportMetrics, setViewportMetrics] = useState<ViewportMetrics>({
offset: 0,
viewportSize: 0,
@@ -187,8 +242,9 @@ export default function TerminalEmulator({
);
useEffect(() => {
mountedThemeRef.current = xtermTheme;
runtimeRef.current?.setTheme({ theme: xtermTheme });
const nextTheme = xtermThemeRef.current;
mountedThemeRef.current = nextTheme;
runtimeRef.current?.setTheme({ theme: nextTheme });
}, [themeKey]);
useEffect(() => {
@@ -322,18 +378,15 @@ export default function TerminalEmulator({
runtimeRef.current = runtime;
runtime.setCallbacks({
callbacks: {
onInput,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
...mountCallbacksRef.current,
onOpenExternalUrl: openExternalUrl,
},
});
runtime.setPendingModifiers({ pendingModifiers });
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
runtime.mount({
root,
host,
initialSnapshot,
initialSnapshot: initialSnapshotRef.current,
theme: mountedThemeRef.current,
});
@@ -546,13 +599,11 @@ export default function TerminalEmulator({
const handleVisible =
scrollbarGeometry.isVisible && (isDraggingScrollbar || isScrollVisible || isHandleHovered);
const handleOpacity = isDraggingScrollbar
? SCROLLBAR_HANDLE_OPACITY_DRAGGING
: isHandleHovered
? SCROLLBAR_HANDLE_OPACITY_HOVERED
: isScrollVisible
? SCROLLBAR_HANDLE_OPACITY_VISIBLE
: 0;
let handleOpacity: number;
if (isDraggingScrollbar) handleOpacity = SCROLLBAR_HANDLE_OPACITY_DRAGGING;
else if (isHandleHovered) handleOpacity = SCROLLBAR_HANDLE_OPACITY_HOVERED;
else if (isScrollVisible) handleOpacity = SCROLLBAR_HANDLE_OPACITY_VISIBLE;
else handleOpacity = 0;
const handleWidth =
isDraggingScrollbar || isHandleHovered
? SCROLLBAR_HANDLE_WIDTH_ACTIVE
@@ -568,7 +619,7 @@ export default function TerminalEmulator({
const handleInsetTop = Math.max(0, (thumbRegionHeight - scrollbarGeometry.handleSize) / 2);
const handleTravelDurationMs =
isDraggingScrollbar || isScrollActive ? 0 : SCROLLBAR_HANDLE_TRAVEL_DURATION_MS;
const handleContextMenu = () => {
const showTerminalContextMenu = useCallback(() => {
const showContextMenu = window.paseoDesktop?.menu?.showContextMenu;
if (typeof showContextMenu !== "function") {
return;
@@ -579,115 +630,117 @@ export default function TerminalEmulator({
kind: "terminal",
hasSelection,
});
};
}, []);
const handleRootPointerDown = useCallback(() => {
runtimeRef.current?.focus();
}, []);
const handleRootContextMenu = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
showTerminalContextMenu();
},
[showTerminalContextMenu],
);
const scrollbarMaxOffset = scrollbarGeometry.maxScrollOffset;
const handleScrollbarPointerDown = useCallback(
(event: ReactPointerEvent) => {
event.preventDefault();
event.stopPropagation();
dragStartOffsetRef.current = clamp(viewportMetrics.offset, 0, scrollbarMaxOffset);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
},
[scrollbarMaxOffset, viewportMetrics.offset],
);
const handleScrollbarPointerEnter = useCallback(() => {
if (!isScrollVisible && !isDraggingScrollbar) {
return;
}
setIsHandleHovered(true);
}, [isScrollVisible, isDraggingScrollbar]);
const handleScrollbarPointerLeave = useCallback(() => {
setIsHandleHovered(false);
}, []);
const rootDivStyle = useMemo<CSSProperties>(
() => ({
position: "relative",
display: "flex",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
backgroundColor: xtermTheme.background ?? "#0b0b0b",
overflow: "hidden",
overscrollBehavior: "none",
touchAction: "pan-y",
}),
[xtermTheme.background],
);
const handleContainerStyle = useMemo<CSSProperties>(
() => ({
position: "absolute",
top: 0,
right: -3,
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
height: thumbRegionHeight,
transform: `translateY(${thumbRegionOffset}px)`,
cursor: isDraggingScrollbar ? "grabbing" : "grab",
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
pointerEvents: handleVisible ? "auto" : "none",
}),
[
thumbRegionHeight,
thumbRegionOffset,
isDraggingScrollbar,
handleTravelDurationMs,
handleVisible,
],
);
const handleInnerStyle = useMemo<CSSProperties>(
() => ({
marginTop: handleInsetTop,
height: scrollbarGeometry.handleSize,
width: handleWidth,
borderRadius: 999,
alignSelf: "center",
backgroundColor: "rgba(113, 113, 122, 1)",
opacity: handleOpacity,
transitionProperty: "opacity, width, background-color",
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}),
[handleInsetTop, scrollbarGeometry.handleSize, handleWidth, handleOpacity],
);
return (
<div
ref={rootRef}
data-testid={testId}
data-terminal-scrollbar-root="true"
style={{
position: "relative",
display: "flex",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
backgroundColor: xtermTheme.background ?? "#0b0b0b",
overflow: "hidden",
overscrollBehavior: "none",
touchAction: "pan-y",
}}
onPointerDown={() => {
runtimeRef.current?.focus();
}}
onContextMenu={(event) => {
event.preventDefault();
handleContextMenu();
}}
style={rootDivStyle}
onPointerDown={handleRootPointerDown}
onContextMenu={handleRootContextMenu}
>
<div
ref={hostRef}
style={{
flex: 1,
minHeight: 0,
minWidth: 0,
width: "100%",
height: "100%",
overflow: "hidden",
overscrollBehavior: "none",
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
}}
/>
<div ref={hostRef} style={HOST_DIV_STYLE} />
{scrollbarGeometry.isVisible ? (
<div
style={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
width: 12,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
zIndex: 10,
pointerEvents: "none",
}}
>
<div style={SCROLLBAR_CONTAINER_STYLE}>
<div
style={{
position: "absolute",
top: 0,
right: -3,
width: SCROLLBAR_HANDLE_GRAB_WIDTH,
height: thumbRegionHeight,
transform: `translateY(${thumbRegionOffset}px)`,
cursor: isDraggingScrollbar ? "grabbing" : "grab",
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
pointerEvents: handleVisible ? "auto" : "none",
}}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
dragStartOffsetRef.current = clamp(
viewportMetrics.offset,
0,
scrollbarGeometry.maxScrollOffset,
);
dragStartClientYRef.current = event.clientY;
setIsDraggingScrollbar(true);
}}
onPointerEnter={() => {
if (!isScrollVisible && !isDraggingScrollbar) {
return;
}
setIsHandleHovered(true);
}}
onPointerLeave={() => {
setIsHandleHovered(false);
}}
style={handleContainerStyle}
onPointerDown={handleScrollbarPointerDown}
onPointerEnter={handleScrollbarPointerEnter}
onPointerLeave={handleScrollbarPointerLeave}
>
<div
style={{
marginTop: handleInsetTop,
height: scrollbarGeometry.handleSize,
width: handleWidth,
borderRadius: 999,
alignSelf: "center",
backgroundColor: "rgba(113, 113, 122, 1)",
opacity: handleOpacity,
transitionProperty: "opacity, width, background-color",
transitionDuration: `${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms, ${SCROLLBAR_HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${SCROLLBAR_HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
}}
/>
<div style={handleInnerStyle} />
</div>
</div>
) : null}
+107 -62
View File
@@ -1,5 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
import {
ActivityIndicator,
Pressable,
ScrollView,
Text,
View,
type PressableStateCallbackType,
} from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
@@ -51,11 +58,11 @@ const KEY_BUTTONS: Array<{ id: string; label: string; key: string }> = [
{ id: "c", label: "C", key: "c" },
];
type ModifierState = {
interface ModifierState {
ctrl: boolean;
shift: boolean;
alt: boolean;
};
}
type PendingTerminalInput =
| {
@@ -83,6 +90,56 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
interface ModifierButtonProps {
modifier: keyof ModifierState;
active: boolean;
onToggle: (modifier: keyof ModifierState) => void;
}
function ModifierButton({ modifier, active, onToggle }: ModifierButtonProps) {
const handlePress = useCallback(() => onToggle(modifier), [onToggle, modifier]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.keyButton,
active && styles.keyButtonActive,
(Boolean(hovered) || pressed) && styles.keyButtonHovered,
],
[active],
);
const textStyle = useMemo(
() => [styles.keyButtonText, active && styles.keyButtonTextActive],
[active],
);
return (
<Pressable testID={`terminal-key-${modifier}`} onPress={handlePress} style={pressableStyle}>
<Text style={textStyle}>{MODIFIER_LABELS[modifier]}</Text>
</Pressable>
);
}
interface VirtualKeyButtonProps {
id: string;
label: string;
keyValue: string;
onSend: (key: string) => void;
}
function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps) {
const handlePress = useCallback(() => onSend(keyValue), [onSend, keyValue]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.keyButton,
(Boolean(hovered) || pressed) && styles.keyButtonHovered,
],
[],
);
return (
<Pressable testID={`terminal-key-${id}`} onPress={handlePress} style={pressableStyle}>
<Text style={styles.keyButtonText}>{label}</Text>
</Pressable>
);
}
export function TerminalPane({
serverId,
cwd,
@@ -260,15 +317,15 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastReportedSizeRef.current,
onOutput: ({ terminalId, text }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
onOutput: ({ terminalId: outputTerminalId, text }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
},
onSnapshot: ({ terminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId, state });
if (!isWorkspaceFocused || terminalIdRef.current !== terminalId) {
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
if (!isWorkspaceFocused || terminalIdRef.current !== snapshotTerminalId) {
return;
}
emulatorRef.current?.renderSnapshot(state);
@@ -467,9 +524,7 @@ export function TerminalPane({
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
modifiers.alt,
modifiers.ctrl,
modifiers.shift,
modifiers,
sendTerminalKey,
enqueuePendingTerminalInput,
],
@@ -543,6 +598,21 @@ export function TerminalPane({
],
);
const containerStyle = useMemo(
() => [styles.container, keyboardPaddingStyle],
[keyboardPaddingStyle],
);
const handleSwipeRight = useCallback(() => {
if (!swipeGesturesEnabled) return;
showMobileAgentList();
}, [swipeGesturesEnabled, showMobileAgentList]);
const handleSwipeLeft = useCallback(() => {
if (!swipeGesturesEnabled) return;
onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]);
if (!client || !isConnected) {
return (
<View style={styles.centerState}>
@@ -552,39 +622,20 @@ export function TerminalPane({
}
return (
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
<Animated.View style={containerStyle}>
<View style={styles.outputContainer}>
{isWorkspaceFocused ? (
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
dom={{
style: { flex: 1 },
matchContents: false,
scrollEnabled: true,
nestedScrollEnabled: true,
overScrollMode: "never",
bounces: false,
automaticallyAdjustContentInsets: false,
contentInsetAdjustmentBehavior: "never",
}}
dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={`${scopeKey}:${terminalId}`}
testId="terminal-surface"
xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onSwipeRight={() => {
if (!swipeGesturesEnabled) {
return;
}
showMobileAgentList();
}}
onSwipeLeft={() => {
if (!swipeGesturesEnabled) {
return;
}
onOpenFileExplorer();
}}
onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft}
onInput={handleTerminalData}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
@@ -618,39 +669,22 @@ export function TerminalPane({
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.keyboardRow}>
{(Object.keys(MODIFIER_LABELS) as Array<keyof ModifierState>).map((modifier) => (
<Pressable
<ModifierButton
key={modifier}
testID={`terminal-key-${modifier}`}
onPress={() => toggleModifier(modifier)}
style={({ hovered, pressed }) => [
styles.keyButton,
modifiers[modifier] && styles.keyButtonActive,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text
style={[
styles.keyButtonText,
modifiers[modifier] && styles.keyButtonTextActive,
]}
>
{MODIFIER_LABELS[modifier]}
</Text>
</Pressable>
modifier={modifier}
active={modifiers[modifier]}
onToggle={toggleModifier}
/>
))}
{KEY_BUTTONS.map((button) => (
<Pressable
<VirtualKeyButton
key={button.id}
testID={`terminal-key-${button.id}`}
onPress={() => sendVirtualKey(button.key)}
style={({ hovered, pressed }) => [
styles.keyButton,
(hovered || pressed) && styles.keyButtonHovered,
]}
>
<Text style={styles.keyButtonText}>{button.label}</Text>
</Pressable>
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
</View>
</ScrollView>
@@ -744,3 +778,14 @@ const styles = StyleSheet.create((theme) => ({
textAlign: "center",
},
}));
const TERMINAL_EMULATOR_DOM_PROPS = {
style: { flex: 1 },
matchContents: false,
scrollEnabled: true,
nestedScrollEnabled: true,
overScrollMode: "never" as const,
bounces: false,
automaticallyAdjustContentInsets: false,
contentInsetAdjustmentBehavior: "never" as const,
};
+38 -31
View File
@@ -15,15 +15,15 @@ import {
export type ToastVariant = "default" | "success" | "error";
export type ToastShowOptions = {
export interface ToastShowOptions {
icon?: ReactNode;
variant?: ToastVariant;
durationMs?: number;
nativeAndroid?: boolean;
testID?: string;
};
}
export type ToastState = {
export interface ToastState {
id: number;
content: ReactNode;
nativeMessage: string | null;
@@ -31,13 +31,13 @@ export type ToastState = {
variant: ToastVariant;
durationMs: number;
testID?: string;
};
}
export type ToastApi = {
export interface ToastApi {
show: (content: ReactNode, options?: ToastShowOptions) => void;
copied: (label?: string) => void;
error: (message: string) => void;
};
}
type ToastViewportPlacement = "app-shell" | "panel";
@@ -212,10 +212,6 @@ export function ToastViewport({
};
}, [clearTimer, opacity, scheduleDismiss, toast, translateY]);
if (!toast) {
return null;
}
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
const topOffset =
@@ -223,13 +219,36 @@ export function ToastViewport({
? insets.top + headerTopPadding + headerHeight + theme.spacing[2]
: theme.spacing[3];
const icon =
toast.icon ??
(toast.variant === "success" ? (
<CheckCircle2 size={18} color={theme.colors.primary} />
) : toast.variant === "error" ? (
<AlertTriangle size={18} color={theme.colors.destructive} />
) : null);
const toastVariant = toast?.variant;
const toastAnimatedStyle = useMemo(
() => [
styles.toast,
toastVariant === "success" ? styles.toastSuccess : null,
toastVariant === "error" ? styles.toastError : null,
{
marginTop: topOffset,
opacity,
transform: [{ translateY }],
},
],
[toastVariant, topOffset, opacity, translateY],
);
const toastMessageStyle = useMemo(
() => [styles.message, toastVariant === "error" ? styles.messageError : null],
[toastVariant],
);
if (!toast) {
return null;
}
let defaultIcon: ReactNode = null;
if (toast.variant === "success") {
defaultIcon = <CheckCircle2 size={18} color={theme.colors.primary} />;
} else if (toast.variant === "error") {
defaultIcon = <AlertTriangle size={18} color={theme.colors.destructive} />;
}
const icon = toast.icon ?? defaultIcon;
const content = (
<View style={styles.container} pointerEvents="box-none">
@@ -237,24 +256,12 @@ export function ToastViewport({
testID={toast.testID ?? "app-toast"}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={[
styles.toast,
toast.variant === "success" ? styles.toastSuccess : null,
toast.variant === "error" ? styles.toastError : null,
{
marginTop: topOffset,
opacity,
transform: [{ translateY }],
},
]}
style={toastAnimatedStyle}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text
testID="app-toast-message"
style={[styles.message, toast.variant === "error" ? styles.messageError : null]}
>
<Text testID="app-toast-message" style={toastMessageStyle}>
{toast.content}
</Text>
) : (
File diff suppressed because it is too large Load Diff
@@ -18,14 +18,14 @@ import { ToolCallDetailsContent } from "./tool-call-details";
// ----- Types -----
export type ToolCallSheetData = {
export interface ToolCallSheetData {
toolName: string;
displayName: string;
summary?: string;
detail?: ToolCallDetail;
errorText?: string;
showLoadingSkeleton?: boolean;
};
}
interface ToolCallSheetContextValue {
openToolCall: (data: ToolCallSheetData) => void;
@@ -102,6 +102,11 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
[openToolCall, closeToolCall],
);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
);
return (
<ToolCallSheetContext.Provider value={contextValue}>
{children}
@@ -114,7 +119,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={CustomSheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
handleIndicatorStyle={handleIndicatorStyle}
>
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</IsolatedBottomSheetModal>
+109 -63
View File
@@ -1,5 +1,14 @@
import { useCallback, useEffect, useRef } from "react";
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
import { useCallback, useEffect, useMemo, useRef } from "react";
import {
ScrollView,
Text,
View,
Pressable,
type LayoutChangeEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { File, Folder } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
@@ -24,7 +33,7 @@ interface AutocompleteProps {
maxHeight?: number;
}
const BOLT_GLYPH_PATTERN = /[\u26A1\uFE0F]/g;
const BOLT_GLYPH_PATTERN = /\u26A1|\uFE0F/gu;
function removeBoltGlyphs(value?: string): string | undefined {
if (!value) {
@@ -34,6 +43,79 @@ function removeBoltGlyphs(value?: string): string | undefined {
return cleaned.length > 0 ? cleaned : undefined;
}
interface AutocompleteRowProps {
index: number;
option: AutocompleteOption;
isSelected: boolean;
mutedColor: string;
onSelect: (option: AutocompleteOption) => void;
onRowLayout: (index: number, event: LayoutChangeEvent) => void;
}
function AutocompleteRow({
index,
option,
isSelected,
mutedColor,
onSelect,
onRowLayout,
}: AutocompleteRowProps) {
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDescription = removeBoltGlyphs(option.description);
const isFileOrDir = option.kind === "directory" || option.kind === "file";
const handleLayout = useCallback(
(event: LayoutChangeEvent) => onRowLayout(index, event),
[index, onRowLayout],
);
const handlePress = useCallback(() => onSelect(option), [onSelect, option]);
const pressableStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.item,
(Boolean(hovered) || pressed || isSelected) && styles.itemActive,
],
[isSelected],
);
return (
<Pressable onLayout={handleLayout} onPress={handlePress} style={pressableStyle}>
{isFileOrDir ? (
<>
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={mutedColor} />
) : (
<File size={14} color={mutedColor} />
)}
</View>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
)}
</Pressable>
);
}
export function Autocomplete({
options,
selectedIndex,
@@ -109,6 +191,10 @@ export function Autocomplete({
[ensureActiveItemVisible],
);
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}, []);
const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, {
@@ -120,9 +206,11 @@ export function Autocomplete({
[ensureActiveItemVisible],
);
const containerStyle = useMemo(() => [styles.container, { maxHeight }], [maxHeight]);
if (isLoading) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{loadingText}</Text>
</View>
@@ -132,7 +220,7 @@ export function Autocomplete({
if (errorMessage) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View>
@@ -142,7 +230,7 @@ export function Autocomplete({
if (options.length === 0) {
return (
<View style={[styles.container, { maxHeight }]}>
<View style={containerStyle}>
<View style={styles.emptyItem}>
<Text style={styles.emptyText}>{emptyText}</Text>
</View>
@@ -167,77 +255,35 @@ export function Autocomplete({
) : null}
</View>
) : null}
<View style={[styles.container, { maxHeight }]}>
<View style={containerStyle}>
<ScrollView
ref={scrollRef}
onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom}
onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
}}
onScroll={handleScroll}
scrollEventThrottle={16}
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="always"
>
{options.map((option, index) => {
const isSelected = index === selectedIndex;
const optionLabel = removeBoltGlyphs(option.label) ?? option.label;
const optionDescription = removeBoltGlyphs(option.description);
const isFileOrDir = option.kind === "directory" || option.kind === "file";
return (
<Pressable
key={option.id}
onLayout={(event) => handleRowLayout(index, event)}
onPress={() => onSelect(option)}
style={({ hovered = false, pressed }) => [
styles.item,
(hovered || pressed || isSelected) && styles.itemActive,
]}
>
{isFileOrDir ? (
<>
<View style={styles.itemLeading}>
{option.kind === "directory" ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
)}
</View>
<View style={styles.itemMain}>
<View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{removeBoltGlyphs(option.detail) ? (
<Text style={styles.itemDetail}>{removeBoltGlyphs(option.detail)}</Text>
) : null}
</View>
{optionDescription ? (
<Text style={styles.itemDescription} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
</>
) : (
<View style={styles.itemMainRow}>
<Text style={styles.itemLabel}>{optionLabel}</Text>
{optionDescription ? (
<Text style={styles.itemDescriptionInline} numberOfLines={1}>
{optionDescription}
</Text>
) : null}
</View>
)}
</Pressable>
);
})}
{options.map((option, index) => (
<AutocompleteRow
key={option.id}
index={index}
option={option}
isSelected={index === selectedIndex}
mutedColor={theme.colors.foregroundMuted}
onSelect={onSelect}
onRowLayout={handleRowLayout}
/>
))}
</ScrollView>
</View>
</View>
);
}
const styles = StyleSheet.create(((theme: Theme) => ({
const styles = StyleSheet.create((theme: Theme) => ({
outerWrapper: {
gap: theme.spacing[1],
},
@@ -339,4 +385,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
})) as any) as Record<string, any>;
})) as unknown as Record<string, object>;
+72 -39
View File
@@ -1,6 +1,19 @@
import { useState, type ComponentType, type PropsWithChildren, type ReactElement } from "react";
import {
useCallback,
useMemo,
useState,
type ComponentType,
type PropsWithChildren,
type ReactElement,
} from "react";
import { Pressable, Text, View } from "react-native";
import type { PressableProps, StyleProp, TextStyle, ViewStyle } from "react-native";
import type {
PressableProps,
PressableStateCallbackType,
StyleProp,
TextStyle,
ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive";
@@ -105,28 +118,55 @@ export function Button({
const [hovered, setHovered] = useState(false);
const { theme } = useUnistyles();
const variantStyle =
variant === "default"
? styles.default
: variant === "secondary"
? styles.secondary
: variant === "outline"
? styles.outline
: variant === "ghost"
? styles.ghost
: styles.destructive;
let variantStyle: ViewStyle;
if (variant === "default") {
variantStyle = styles.default;
} else if (variant === "secondary") {
variantStyle = styles.secondary;
} else if (variant === "outline") {
variantStyle = styles.outline;
} else if (variant === "ghost") {
variantStyle = styles.ghost;
} else {
variantStyle = styles.destructive;
}
const sizeStyle = size === "sm" ? styles.sm : size === "lg" ? styles.lg : styles.md;
let sizeStyle: ViewStyle;
if (size === "sm") {
sizeStyle = styles.sm;
} else if (size === "lg") {
sizeStyle = styles.lg;
} else {
sizeStyle = styles.md;
}
const isGhostHovered = hovered && variant === "ghost";
const resolvedTextStyle = [
styles.text,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle,
isGhostHovered ? styles.textGhostHovered : null,
];
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
const pressableStyle = useCallback(
({ pressed }: PressableStateCallbackType): StyleProp<ViewStyle> => [
styles.base,
sizeStyle,
variantStyle,
pressed ? styles.pressed : null,
disabled ? styles.disabled : null,
style,
],
[sizeStyle, variantStyle, disabled, style],
);
const resolvedTextStyle = useMemo(
() => [
styles.text,
variant === "default" ? styles.textDefault : null,
variant === "destructive" ? styles.textDestructive : null,
variant === "ghost" ? styles.textGhost : null,
textStyle,
isGhostHovered ? styles.textGhostHovered : null,
],
[variant, textStyle, isGhostHovered],
);
function renderIcon() {
if (!leftIcon) return null;
@@ -136,14 +176,14 @@ export function Button({
return <View>{leftIcon}</View>;
}
const color =
variant === "default"
? theme.colors.accentForeground
: variant === "ghost"
? isGhostHovered
? theme.colors.foreground
: theme.colors.foregroundMuted
: theme.colors.foreground;
let color: string;
if (variant === "default") {
color = theme.colors.accentForeground;
} else if (variant === "ghost") {
color = isGhostHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
} else {
color = theme.colors.foreground;
}
const iconSize = ICON_SIZE[size];
// Render function
@@ -169,16 +209,9 @@ export function Button({
{...props}
accessibilityRole={accessibilityRole ?? "button"}
disabled={disabled}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
style={({ pressed }) => [
styles.base,
sizeStyle,
variantStyle,
pressed ? styles.pressed : null,
disabled ? styles.disabled : null,
style,
]}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
>
{renderIcon()}
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
File diff suppressed because it is too large Load Diff
+191 -109
View File
@@ -9,6 +9,7 @@ import {
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
type Ref,
type MutableRefObject,
} from "react";
@@ -22,7 +23,9 @@ import {
StatusBar,
Text,
View,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
@@ -53,13 +56,13 @@ interface Rect {
height: number;
}
type ContextMenuContextValue = {
interface ContextMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
anchorRect: Rect | null;
setAnchorRect: (rect: Rect | null) => void;
};
}
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
@@ -173,7 +176,17 @@ function computePosition({
}
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
const nativeEvent: any = (event as any)?.nativeEvent ?? event;
const wrapper = event as
| {
nativeEvent?: { pageX?: number; pageY?: number; clientX?: number; clientY?: number };
pageX?: number;
pageY?: number;
clientX?: number;
clientY?: number;
}
| null
| undefined;
const nativeEvent = wrapper?.nativeEvent ?? wrapper;
const pageX = nativeEvent?.pageX;
const pageY = nativeEvent?.pageY;
if (typeof pageX === "number" && typeof pageY === "number") {
@@ -235,7 +248,11 @@ export function ContextMenu({
return <ContextMenuContext.Provider value={value}>{children}</ContextMenuContext.Provider>;
}
type TriggerState = { pressed: boolean; hovered: boolean; open: boolean };
interface TriggerState {
pressed: boolean;
hovered: boolean;
open: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
export function ContextMenuTrigger({
@@ -291,6 +308,42 @@ export function ContextMenuTrigger({
[ctx.triggerRef, triggerRef],
);
const propsOnLongPress = props.onLongPress;
const handleLongPress = useCallback(
(event: GestureResponderEvent) => {
if (isWeb) {
propsOnLongPress?.(event);
return;
}
openAtEvent(event);
propsOnLongPress?.(event);
},
[propsOnLongPress, openAtEvent],
);
const handleContextMenu = useCallback(
(event: unknown) => {
if (isNative) {
return;
}
const e = event as { preventDefault?: () => void; stopPropagation?: () => void } | undefined;
e?.preventDefault?.();
e?.stopPropagation?.();
openAtEvent(event as GestureResponderEvent);
},
[openAtEvent],
);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
},
[style, ctx.open],
);
return (
<Pressable
{...props}
@@ -298,30 +351,10 @@ export function ContextMenuTrigger({
collapsable={false}
disabled={disabled}
delayLongPress={longPressDelayMs}
onLongPress={(event) => {
if (isWeb) {
props.onLongPress?.(event);
return;
}
openAtEvent(event);
props.onLongPress?.(event);
}}
onLongPress={handleLongPress}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={(event: unknown) => {
if (isNative) {
return;
}
const e: any = event;
e?.preventDefault?.();
e?.stopPropagation?.();
openAtEvent(event);
}}
style={({ pressed, hovered = false }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
}}
onContextMenu={handleContextMenu}
style={pressableStyle}
>
{children}
</Pressable>
@@ -380,6 +413,21 @@ export function ContextMenuContent({
[],
);
const sheetBackgroundStyle = useMemo(
() => [
styles.sheetBackground,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
],
[theme.colors.surface0, theme.colors.border],
);
const sheetHandleStyle = useMemo(
() => [styles.sheetHandle, { backgroundColor: theme.colors.surface2 }],
[theme.colors.surface2],
);
// Measure trigger when opening (fallback) and capture point anchors.
useEffect(() => {
if (useMobileSheet) {
@@ -415,6 +463,7 @@ export function ContextMenuContent({
...rect,
y: rect.y + statusBarHeight,
});
return;
});
return () => {
@@ -456,6 +505,26 @@ export function ContextMenuContent({
[],
);
const animatedContentStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
];
}, [fullWidth, horizontalPadding, width, minWidth, maxWidth, position?.y, position?.x]);
if (useMobileSheet) {
return (
<ContextMenuContext.Provider value={context}>
@@ -467,14 +536,8 @@ export function ContextMenuContent({
onChange={handleSheetChange}
backdropComponent={renderSheetBackdrop}
enablePanDownToClose
backgroundStyle={[
styles.sheetBackground,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
handleIndicatorStyle={[styles.sheetHandle, { backgroundColor: theme.colors.surface2 }]}
backgroundStyle={sheetBackgroundStyle}
handleIndicatorStyle={sheetHandleStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
@@ -493,15 +556,6 @@ export function ContextMenuContent({
if (!open) return null;
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return (
<Modal
visible={open}
@@ -524,21 +578,13 @@ export function ContextMenuContent({
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
style={animatedContentStyle}
>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
</ScrollView>
@@ -553,8 +599,9 @@ export function ContextMenuLabel({
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const containerStyle = useMemo(() => [styles.labelContainer, style], [style]);
return (
<View style={[styles.labelContainer, style]} testID={testID}>
<View style={containerStyle} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
@@ -567,7 +614,8 @@ export function ContextMenuSeparator({
style?: ViewStyle;
testID?: string;
}): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
return <View style={separatorStyle} testID={testID} />;
}
export function ContextMenuHint({
@@ -581,6 +629,34 @@ export function ContextMenuHint({
);
}
function resolveLeadingContent(input: {
isPending: boolean;
isSuccess: boolean;
leading: ReactElement | null | undefined;
pendingColor: string;
successColor: string;
}): ReactElement | null {
if (input.isPending) {
return <ActivityIndicator size={16} color={input.pendingColor} />;
}
if (input.isSuccess) {
return <CheckCircle size={16} color={input.successColor} />;
}
return input.leading ?? null;
}
function resolveItemLabel(input: {
children: ReactNode;
isPending: boolean;
isSuccess: boolean;
pendingLabel: string | undefined;
successLabel: string | undefined;
}): ReactNode {
if (input.isPending && input.pendingLabel) return input.pendingLabel;
if (input.isSuccess && input.successLabel) return input.successLabel;
return input.children;
}
export function ContextMenuItem({
children,
description,
@@ -621,25 +697,19 @@ export function ContextMenuItem({
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
const isPending = status === "pending" || loading;
const isPending = status === "pending" || Boolean(loading);
const isSuccess = status === "success";
const isDisabled = disabled || isPending || isSuccess;
const isDisabled = Boolean(disabled) || isPending || isSuccess;
let leadingContent: ReactElement | null = null;
if (isPending) {
leadingContent = <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
} else if (isSuccess) {
leadingContent = <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
} else if (leading) {
leadingContent = leading;
}
const leadingContent = resolveLeadingContent({
isPending,
isSuccess,
leading,
pendingColor: theme.colors.foregroundMuted,
successColor: theme.colors.palette.green[500],
});
let label = children;
if (isPending && pendingLabel) {
label = pendingLabel;
} else if (isSuccess && successLabel) {
label = successLabel;
}
const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel });
const trailingContent =
trailing ??
@@ -647,32 +717,58 @@ export function ContextMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}}
style={({ pressed, hovered }) => [
const handleItemPress = useCallback(() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}, [isDisabled, closeOnSelect, setOpen, onSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null;
if (!selected) selectedStyle = null;
else if (selectedVariant === "accent") selectedStyle = styles.itemSelectedAccent;
else selectedStyle = styles.itemSelected;
return [
styles.item,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selectedStyle,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
]}
];
},
[selected, selectedVariant, isDisabled],
);
const itemTextStyle = useMemo(
() => [
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
],
[destructive, isSuccess, selected, selectedVariant],
);
const itemDescriptionStyle = useMemo(
() => [
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
],
[selected, selectedVariant],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={handleItemPress}
style={itemPressableStyle}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
@@ -681,27 +777,11 @@ export function ContextMenuItem({
) : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text
numberOfLines={1}
style={[
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
]}
>
<Text numberOfLines={1} style={itemTextStyle}>
{label}
</Text>
{description && !isPending && !isSuccess ? (
<Text
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent"
? styles.itemDescriptionSelectedAccent
: null,
]}
>
<Text numberOfLines={2} style={itemDescriptionStyle}>
{description}
</Text>
) : null}
@@ -854,3 +934,5 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.85,
},
}));
const SCROLL_CONTENT_CONTAINER_STYLE = { flexGrow: 1 };
+170 -91
View File
@@ -21,6 +21,7 @@ import {
Platform,
StatusBar,
type PressableProps,
type PressableStateCallbackType,
type ViewStyle,
type StyleProp,
} from "react-native";
@@ -33,6 +34,8 @@ import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
// Action status for menu items with loading/success feedback
export type ActionStatus = "idle" | "pending" | "success";
const DROPDOWN_SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
type Placement = "top" | "bottom" | "left" | "right";
type Alignment = "start" | "center" | "end";
@@ -43,13 +46,13 @@ interface Rect {
height: number;
}
type DropdownMenuContextValue = {
interface DropdownMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
flushPendingSelect: () => void;
triggerRef: React.RefObject<View | null>;
};
}
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
@@ -224,7 +227,11 @@ export function DropdownMenu({
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
}
type TriggerState = { pressed: boolean; hovered: boolean; open: boolean };
interface TriggerState {
pressed: boolean;
hovered: boolean;
open: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
interface DropdownMenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
@@ -245,6 +252,24 @@ export function DropdownMenuTrigger({
ctx.setOpen(!ctx.open);
}, [disabled, ctx]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
},
[style, ctx.open],
);
const renderChildren = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
return typeof children === "function" ? children(state) : children;
},
[children, ctx.open],
);
return (
<Pressable
{...props}
@@ -252,24 +277,22 @@ export function DropdownMenuTrigger({
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={({ pressed, hovered = false }) => {
if (typeof style === "function") {
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
}}
style={pressableStyle}
>
{({ pressed, hovered = false }) => {
const state: TriggerState = { pressed, hovered: Boolean(hovered), open: ctx.open };
return typeof children === "function" ? children(state) : children;
}}
{renderChildren}
</Pressable>
);
}
function getTransformOrigin(placement: Placement, alignment: Alignment): string {
const vertical = placement === "bottom" ? "top" : placement === "top" ? "bottom" : "center";
const horizontal = alignment === "start" ? "left" : alignment === "end" ? "right" : "center";
let vertical: string;
if (placement === "bottom") vertical = "top";
else if (placement === "top") vertical = "bottom";
else vertical = "center";
let horizontal: string;
if (alignment === "start") horizontal = "left";
else if (alignment === "end") horizontal = "right";
else horizontal = "center";
return `${vertical} ${horizontal}`;
}
@@ -362,6 +385,7 @@ export function DropdownMenuContent({
...rect,
y: rect.y + statusBarHeight,
});
return;
});
return () => {
@@ -406,16 +430,38 @@ export function DropdownMenuContent({
[],
);
if (!modalVisible) return null;
const contentStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
styles.content,
resolvedWidthStyle,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
transformOrigin: getTransformOrigin(actualPlacement, align),
},
];
}, [
fullWidth,
horizontalPadding,
width,
minWidth,
maxWidth,
position?.x,
position?.y,
actualPlacement,
align,
]);
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
if (!modalVisible) return null;
return (
<Modal
@@ -446,22 +492,13 @@ export function DropdownMenuContent({
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={[
styles.content,
resolvedWidthStyle,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
transformOrigin: getTransformOrigin(actualPlacement, align),
},
]}
style={contentStyle}
>
<ScrollView
bounces={false}
showsVerticalScrollIndicator
style={webScrollbarStyle}
contentContainerStyle={{ flexGrow: 1 }}
contentContainerStyle={DROPDOWN_SCROLL_CONTENT_STYLE}
>
{children}
</ScrollView>
@@ -477,8 +514,9 @@ export function DropdownMenuLabel({
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const labelContainerStyle = useMemo(() => [styles.labelContainer, style], [style]);
return (
<View style={[styles.labelContainer, style]} testID={testID}>
<View style={labelContainerStyle} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
@@ -491,7 +529,8 @@ export function DropdownMenuSeparator({
style?: ViewStyle;
testID?: string;
}): ReactElement {
return <View style={[styles.separator, style]} testID={testID} />;
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
return <View style={separatorStyle} testID={testID} />;
}
export function DropdownMenuHint({
@@ -505,6 +544,35 @@ export function DropdownMenuHint({
);
}
function resolveDropdownItemLeadingContent(input: {
isPending: boolean | undefined;
isSuccess: boolean;
leading: ReactElement | null;
theme: { colors: { foregroundMuted: string; palette: { green: Record<number, string> } } };
}): ReactElement | null {
const { isPending, isSuccess, leading, theme } = input;
if (isPending) {
return <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
}
if (isSuccess) {
return <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
}
return leading;
}
function resolveDropdownItemLabel(input: {
children: ReactNode;
isPending: boolean | undefined;
isSuccess: boolean;
pendingLabel?: string;
successLabel?: string;
}): ReactNode {
const { children, isPending, isSuccess, pendingLabel, successLabel } = input;
if (isPending && pendingLabel) return pendingLabel;
if (isSuccess && successLabel) return successLabel;
return children;
}
export function DropdownMenuItem({
children,
description,
@@ -555,23 +623,20 @@ export function DropdownMenuItem({
const isSuccess = status === "success";
const isDisabled = disabled || isPending || isSuccess;
// Determine leading icon based on status
let leadingContent: ReactElement | null = null;
if (isPending) {
leadingContent = <ActivityIndicator size={16} color={theme.colors.foregroundMuted} />;
} else if (isSuccess) {
leadingContent = <CheckCircle size={16} color={theme.colors.palette.green[500]} />;
} else if (leading) {
leadingContent = leading;
}
const leadingContent = resolveDropdownItemLeadingContent({
isPending,
isSuccess,
leading: leading ?? null,
theme,
});
// Determine label based on status
let label = children;
if (isPending && pendingLabel) {
label = pendingLabel;
} else if (isSuccess && successLabel) {
label = successLabel;
}
const label = resolveDropdownItemLabel({
children,
isPending,
isSuccess,
pendingLabel,
successLabel,
});
const trailingContent =
trailing ??
@@ -579,30 +644,61 @@ export function DropdownMenuItem({
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
const handleItemPress = useCallback(() => {
if (isDisabled) return;
selectItem(onSelect, closeOnSelect);
}, [isDisabled, selectItem, onSelect, closeOnSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null =
null;
if (selected && selectedVariant === "accent") {
selectedStyle = styles.itemSelectedAccent;
} else if (selected) {
selectedStyle = styles.itemSelected;
}
return [
styles.item,
selectedStyle,
selected && (Boolean(hovered) || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
Boolean(hovered) && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
];
},
[selected, selectedVariant, isDisabled, muted],
);
const itemTextStyle = useMemo(
() => [
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
muted && !isDisabled ? styles.itemTextMuted : null,
],
[destructive, isSuccess, selected, selectedVariant, muted, isDisabled],
);
const itemDescriptionStyle = useMemo(
() => [
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
],
[selected, selectedVariant],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={() => {
if (isDisabled) return;
selectItem(onSelect, closeOnSelect);
}}
style={({ pressed, hovered }) => [
styles.item,
selected
? selectedVariant === "accent"
? styles.itemSelectedAccent
: styles.itemSelected
: null,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
]}
onPress={handleItemPress}
style={itemPressableStyle}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
@@ -611,28 +707,11 @@ export function DropdownMenuItem({
) : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text
numberOfLines={1}
style={[
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
muted && !isDisabled ? styles.itemTextMuted : null,
]}
>
<Text numberOfLines={1} style={itemTextStyle}>
{label}
</Text>
{description && !isPending && !isSuccess ? (
<Text
numberOfLines={2}
style={[
styles.itemDescription,
selected && selectedVariant === "accent"
? styles.itemDescriptionSelectedAccent
: null,
]}
>
<Text numberOfLines={2} style={itemDescriptionStyle}>
{description}
</Text>
) : null}
@@ -8,6 +8,12 @@ import {
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
const SNAP_POINTS_50: (string | number)[] = ["50%"];
const SNAP_POINTS_60: (string | number)[] = ["60%"];
const SNAP_POINTS_90: (string | number)[] = ["90%"];
function noop(): void {}
const { modalMethods, modalProps } = vi.hoisted(() => ({
modalMethods: {
present: vi.fn(),
@@ -57,7 +63,7 @@ function Harness({ visible, onClose }: { visible: boolean; onClose: () => void }
<IsolatedBottomSheetModal
ref={sheetRef}
index={0}
snapPoints={["50%"]}
snapPoints={SNAP_POINTS_50}
onChange={handleSheetChange}
>
<div>Sheet content</div>
@@ -112,8 +118,8 @@ describe("IsolatedBottomSheetModal", () => {
it("allows nested sheets inside a parent sheet without creating a sibling provider", () => {
const { getAllByTestId } = render(
<IsolatedBottomSheetModal index={0} snapPoints={["90%"]}>
<IsolatedBottomSheetModal index={0} snapPoints={["60%"]} onChange={() => {}}>
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_90}>
<IsolatedBottomSheetModal index={0} snapPoints={SNAP_POINTS_60} onChange={noop}>
<div>Nested model picker</div>
</IsolatedBottomSheetModal>
</IsolatedBottomSheetModal>,
@@ -1,21 +1,21 @@
import type { ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import type { StyleProp, ViewStyle } from "react-native";
import { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
type SegmentedControlSize = "sm" | "md";
type SegmentedControlIconRenderer = (props: { color: string; size: number }) => ReactNode;
export type SegmentedControlOption<T extends string> = {
export interface SegmentedControlOption<T extends string> {
value: T;
label: string;
icon?: SegmentedControlIconRenderer;
disabled?: boolean;
testID?: string;
};
}
type SegmentedControlProps<T extends string> = {
interface SegmentedControlProps<T extends string> {
options: SegmentedControlOption<T>[];
value: T;
onValueChange: (value: T) => void;
@@ -23,7 +23,7 @@ type SegmentedControlProps<T extends string> = {
hideLabels?: boolean;
style?: StyleProp<ViewStyle>;
testID?: string;
};
}
export function SegmentedControl<T extends string>({
options,
@@ -40,53 +40,104 @@ export function SegmentedControl<T extends string>({
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
const iconSize = size === "sm" ? theme.iconSize.sm : theme.iconSize.md;
const containerStyle = useMemo(
() => [styles.container, containerSizeStyle, style],
[containerSizeStyle, style],
);
return (
<View style={[styles.container, containerSizeStyle, style]} testID={testID}>
<View style={containerStyle} testID={testID}>
{options.map((option) => {
const isSelected = option.value === value;
const iconColor = isSelected ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<Pressable
<SegmentItem
key={option.value}
accessibilityRole="button"
accessibilityState={{ selected: isSelected, disabled: option.disabled }}
disabled={option.disabled}
testID={option.testID}
onPress={() => {
if (!option.disabled && option.value !== value) {
onValueChange(option.value);
}
}}
style={({ hovered, pressed }) => [
styles.segment,
segmentSizeStyle,
isSelected && styles.segmentSelected,
hovered && !isSelected && styles.segmentHover,
pressed && !isSelected && styles.segmentPressed,
option.disabled && styles.segmentDisabled,
]}
>
{option.icon ? (
<View style={styles.iconContainer}>
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
{hideLabels ? null : (
<Text
style={[styles.label, labelSizeStyle, isSelected && styles.labelSelected]}
numberOfLines={1}
>
{option.label}
</Text>
)}
</Pressable>
option={option}
isSelected={isSelected}
iconColor={iconColor}
iconSize={iconSize}
hideLabels={hideLabels}
segmentSizeStyle={segmentSizeStyle}
labelSizeStyle={labelSizeStyle}
currentValue={value}
onValueChange={onValueChange}
/>
);
})}
</View>
);
}
function SegmentItem<T extends string>({
option,
isSelected,
iconColor,
iconSize,
hideLabels,
segmentSizeStyle,
labelSizeStyle,
currentValue,
onValueChange,
}: {
option: SegmentedControlOption<T>;
isSelected: boolean;
iconColor: string;
iconSize: number;
hideLabels: boolean;
segmentSizeStyle: StyleProp<ViewStyle>;
labelSizeStyle: StyleProp<TextStyle>;
currentValue: T;
onValueChange: (value: T) => void;
}) {
const labelStyle = useMemo(
() => [styles.label, labelSizeStyle, isSelected && styles.labelSelected],
[labelSizeStyle, isSelected],
);
const handlePress = useCallback(() => {
if (!option.disabled && option.value !== currentValue) {
onValueChange(option.value);
}
}, [option.disabled, option.value, currentValue, onValueChange]);
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.segment,
segmentSizeStyle,
isSelected && styles.segmentSelected,
Boolean(hovered) && !isSelected && styles.segmentHover,
pressed && !isSelected && styles.segmentPressed,
option.disabled && styles.segmentDisabled,
],
[isSelected, option.disabled, segmentSizeStyle],
);
const accessibilityState = useMemo(
() => ({ selected: isSelected, disabled: option.disabled }),
[isSelected, option.disabled],
);
return (
<Pressable
accessibilityRole="button"
accessibilityState={accessibilityState}
disabled={option.disabled}
testID={option.testID}
onPress={handlePress}
style={pressableStyle}
>
{option.icon ? (
<View style={styles.iconContainer}>
{option.icon({ color: iconColor, size: iconSize })}
</View>
) : null}
{hideLabels ? null : (
<Text style={labelStyle} numberOfLines={1}>
{option.label}
</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
+11 -7
View File
@@ -1,4 +1,4 @@
import type { ReactElement } from "react";
import { useMemo, type ReactElement } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
@@ -19,24 +19,28 @@ export function Shortcut({
const shortcutOs = getShortcutOs();
const singleCombo = displayChord[0];
const badgeStyle = useMemo(() => [styles.badge, style], [style]);
const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
const sequenceStyle = useMemo(() => [styles.sequence, style], [style]);
if (!singleCombo) {
return <View style={style} />;
}
if (displayChord.length === 1) {
return (
<View style={[styles.badge, style]}>
<Text style={[styles.text, textStyle]}>{formatShortcut(singleCombo, shortcutOs)}</Text>
<View style={badgeStyle}>
<Text style={textCombinedStyle}>{formatShortcut(singleCombo, shortcutOs)}</Text>
</View>
);
}
return (
<View style={[styles.sequence, style]}>
{displayChord.map(function (combo, index) {
<View style={sequenceStyle}>
{displayChord.map(function (combo) {
return (
<View key={`${combo.join("+")}-${index}`} style={styles.badge}>
<Text style={[styles.text, textStyle]}>{formatShortcut(combo, shortcutOs)}</Text>
<View key={combo.join("+")} style={styles.badge}>
<Text style={textCombinedStyle}>{formatShortcut(combo, shortcutOs)}</Text>
</View>
);
})}
+20 -18
View File
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { View, Text } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
type StatusBadgeVariant = "success" | "error" | "muted";
@@ -9,25 +10,26 @@ interface StatusBadgeProps {
}
export function StatusBadge({ label, variant = "muted" }: StatusBadgeProps) {
const { theme } = useUnistyles();
const pillStyle = useMemo(
() => [
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
],
[variant],
);
const textStyle = useMemo(
() => [
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
],
[variant],
);
return (
<View
style={[
styles.pill,
variant === "success" && styles.pillSuccess,
variant === "error" && styles.pillError,
]}
>
<Text
style={[
styles.pillText,
variant === "success" && styles.pillTextSuccess,
variant === "error" && styles.pillTextError,
]}
>
{label}
</Text>
<View style={pillStyle}>
<Text style={textStyle}>{label}</Text>
</View>
);
}
@@ -16,7 +16,7 @@ vi.mock("@/constants/layout", () => ({
}));
vi.mock("@gorhom/portal", () => ({
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Portal: ({ children }: { children: React.ReactNode }) => children,
}));
vi.mock("@gorhom/bottom-sheet", () => ({
+101 -90
View File
@@ -41,14 +41,14 @@ interface Rect {
height: number;
}
type TooltipContextValue = {
interface TooltipContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
enabled: boolean;
openOnPress: boolean;
delayDuration: number;
};
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
@@ -132,6 +132,37 @@ function measureElement(element: View): Promise<Rect> {
});
}
function resolveActualSide(args: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
side: Side;
}): Side {
const { triggerRect, contentSize, displayArea, side } = args;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
if (side === "bottom" && spaceBottom < contentSize.height && spaceTop > spaceBottom) return "top";
if (side === "top" && spaceTop < contentSize.height && spaceBottom > spaceTop) return "bottom";
if (side === "left" && spaceLeft < contentSize.width && spaceRight > spaceLeft) return "right";
if (side === "right" && spaceRight < contentSize.width && spaceLeft > spaceRight) return "left";
return side;
}
function resolveAlignedCoordinate(args: {
align: Align;
start: number;
size: number;
contentSize: number;
}): number {
const { align, start, size, contentSize } = args;
if (align === "start") return start;
if (align === "end") return start + size - contentSize;
return start + (size - contentSize) / 2;
}
function computePosition({
triggerRect,
contentSize,
@@ -148,62 +179,43 @@ function computePosition({
offset: number;
}): { x: number; y: number; actualSide: Side } {
const { width: contentWidth, height: contentHeight } = contentSize;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
const spaceLeft = triggerRect.x - displayArea.x;
const spaceRight = displayArea.x + displayArea.width - (triggerRect.x + triggerRect.width);
let actualSide = side;
if (side === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualSide = "top";
} else if (side === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualSide = "bottom";
} else if (side === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
actualSide = "right";
} else if (side === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
actualSide = "left";
}
const actualSide = resolveActualSide({ triggerRect, contentSize, displayArea, side });
let x = 0;
let y = 0;
if (actualSide === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
x = resolveAlignedCoordinate({
align,
start: triggerRect.x,
size: triggerRect.width,
contentSize: contentWidth,
});
} else if (actualSide === "top") {
y = triggerRect.y - contentHeight - offset;
if (align === "start") {
x = triggerRect.x;
} else if (align === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
x = resolveAlignedCoordinate({
align,
start: triggerRect.x,
size: triggerRect.width,
contentSize: contentWidth,
});
} else if (actualSide === "left") {
x = triggerRect.x - contentWidth - offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
y = resolveAlignedCoordinate({
align,
start: triggerRect.y,
size: triggerRect.height,
contentSize: contentHeight,
});
} else {
x = triggerRect.x + triggerRect.width + offset;
if (align === "start") {
y = triggerRect.y;
} else if (align === "end") {
y = triggerRect.y + triggerRect.height - contentHeight;
} else {
y = triggerRect.y + (triggerRect.height - contentHeight) / 2;
}
y = resolveAlignedCoordinate({
align,
start: triggerRect.y,
size: triggerRect.height,
contentSize: contentHeight,
});
}
const padding = 8;
@@ -307,24 +319,24 @@ export function TooltipTrigger({
}, [clearOpenTimer]);
const handleHoverIn = useCallback(
(e?: any) => {
onHoverIn?.(e);
(e?: unknown) => {
onHoverIn?.(e as never);
scheduleOpen();
},
[onHoverIn, scheduleOpen],
);
const handleHoverOut = useCallback(
(e?: any) => {
onHoverOut?.(e);
(e?: unknown) => {
onHoverOut?.(e as never);
close();
},
[onHoverOut, close],
);
const handleFocus = useCallback(
(e: any) => {
onFocus?.(e);
(e: unknown) => {
onFocus?.(e as never);
if (!ctx.enabled || disabled) return;
if (!shouldOpenOnFocus()) return;
clearOpenTimer();
@@ -334,16 +346,16 @@ export function TooltipTrigger({
);
const handleBlur = useCallback(
(e: any) => {
onBlur?.(e);
(e: unknown) => {
onBlur?.(e as never);
close();
},
[close, onBlur],
);
const handlePress = useCallback(
(e: any) => {
onPress?.(e);
(e: unknown) => {
onPress?.(e as never);
if (!ctx.enabled || disabled) {
return;
}
@@ -372,7 +384,7 @@ export function TooltipTrigger({
onPointerLeave: handleHoverOut,
onMouseEnter: handleHoverIn,
onMouseLeave: handleHoverOut,
} as any)
} as object)
: null),
};
@@ -382,21 +394,21 @@ export function TooltipTrigger({
throw new Error("TooltipTrigger with asChild expects a single React element child");
}
const childProps = child.props as Record<string, any>;
const childProps = child.props as Record<string, unknown>;
const mergedProps = {
...childProps,
...triggerProps,
disabled: childProps.disabled || disabled,
onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus, handleFocus),
onBlur: composeEventHandlers(childProps.onBlur, handleBlur),
onPress: composeEventHandlers(childProps.onPress, handlePress),
onPointerEnter: composeEventHandlers(childProps.onPointerEnter, handleHoverIn),
onPointerLeave: composeEventHandlers(childProps.onPointerLeave, handleHoverOut),
onMouseEnter: composeEventHandlers(childProps.onMouseEnter, handleHoverIn),
onMouseLeave: composeEventHandlers(childProps.onMouseLeave, handleHoverOut),
} as Record<string, any>;
onHoverIn: composeEventHandlers(childProps.onHoverIn as never, handleHoverIn),
onHoverOut: composeEventHandlers(childProps.onHoverOut as never, handleHoverOut),
onFocus: composeEventHandlers(childProps.onFocus as never, handleFocus),
onBlur: composeEventHandlers(childProps.onBlur as never, handleBlur),
onPress: composeEventHandlers(childProps.onPress as never, handlePress),
onPointerEnter: composeEventHandlers(childProps.onPointerEnter as never, handleHoverIn),
onPointerLeave: composeEventHandlers(childProps.onPointerLeave as never, handleHoverOut),
onMouseEnter: composeEventHandlers(childProps.onMouseEnter as never, handleHoverIn),
onMouseLeave: composeEventHandlers(childProps.onMouseLeave as never, handleHoverOut),
} as Record<string, unknown>;
const existingRefProp = childProps[triggerRefProp] as Ref<View | null> | undefined;
mergedProps[triggerRefProp] = (node: View | null) => {
@@ -450,6 +462,7 @@ export function TooltipContent({
measureElement(ctx.triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return;
});
return () => {
@@ -480,6 +493,22 @@ export function TooltipContent({
[],
);
const contentStyle = useMemo(
() => [
styles.content,
{ maxWidth },
style,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
],
[maxWidth, style, position?.x, position?.y],
);
const handleDismiss = useCallback(() => ctx.setOpen(false), [ctx]);
if (!ctx.open || !ctx.enabled) return null;
// On web, avoid React Native's <Modal/> implementation (it uses <dialog> and can
@@ -496,16 +525,7 @@ export function TooltipContent({
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
style={contentStyle}
>
{children}
</Animated.View>
@@ -520,9 +540,9 @@ export function TooltipContent({
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={() => ctx.setOpen(false)}
onRequestClose={handleDismiss}
>
<Pressable style={styles.overlay} onPress={() => ctx.setOpen(false)}>
<Pressable style={styles.overlay} onPress={handleDismiss}>
<Animated.View
pointerEvents="none"
entering={FadeIn.duration(80)}
@@ -530,16 +550,7 @@ export function TooltipContent({
collapsable={false}
testID={testID}
onLayout={handleLayout}
style={[
styles.content,
{ maxWidth },
style,
{
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
style={contentStyle}
>
{children}
</Animated.View>
@@ -3,16 +3,16 @@ import type { BottomAnchorTransportBehavior } from "./agent-stream-render-strate
export type BottomAnchorMode = "sticky-bottom" | "detached";
export type BottomAnchorRouteRequest = {
export interface BottomAnchorRouteRequest {
reason: "initial-entry" | "resume";
agentId: string;
requestKey: string;
};
}
export type BottomAnchorLocalRequest = {
export interface BottomAnchorLocalRequest {
reason: "jump-to-bottom" | "message-sent";
agentId: string;
};
}
export type BottomAnchorBlockedReason =
| "waiting_for_history_readiness"
@@ -24,14 +24,14 @@ type BottomAnchorRequestReason =
| BottomAnchorRouteRequest["reason"]
| BottomAnchorLocalRequest["reason"];
type BottomAnchorRequest = {
interface BottomAnchorRequest {
id: number;
agentId: string;
reason: BottomAnchorRequestReason;
requestKey: string;
};
}
type ControllerMeasurementState = {
interface ControllerMeasurementState {
containerKey: string;
viewportWidth: number;
viewportHeight: number;
@@ -39,37 +39,25 @@ type ControllerMeasurementState = {
offsetY: number;
viewportMeasuredForKey: string | null;
contentMeasuredForKey: string | null;
};
}
type AttemptContext = {
interface AttemptContext {
requestId: number | null;
retries: number;
confirmationPasses?: number;
startedContentHeight?: number;
startedOffsetY?: number;
startedViewportHeight?: number;
};
}
type ScheduledFrameHandle = {
interface ScheduledFrameHandle {
cancelled: boolean;
rafId: number | null;
remainingFrames: number;
callback: () => void;
};
}
type BottomAnchorEvent =
| "request_created"
| "evaluate_called"
| "attempt_started"
| "attempt_verified"
| "attempt_failed"
| "request_fulfilled"
| "request_cancelled"
| "detached_by_user"
| "verification_scheduled"
| "blocked_reason_changed";
type BottomAnchorControllerDriver = {
interface BottomAnchorControllerDriver {
destroy: () => void;
getSnapshot: () => {
mode: BottomAnchorMode;
@@ -99,9 +87,9 @@ type BottomAnchorControllerDriver = {
}) => void;
notifyAuthoritativeHistoryMaybeChanged: () => void;
reevaluate: (animated?: boolean) => void;
};
}
type CreateBottomAnchorControllerDriverInput = {
interface CreateBottomAnchorControllerDriverInput {
getAgentId: () => string;
getIsAuthoritativeHistoryReady: () => boolean;
getRenderStrategy: () => string;
@@ -116,7 +104,7 @@ type CreateBottomAnchorControllerDriverInput = {
delayFrames?: number;
}) => unknown;
cancelFrame: (handle: unknown) => void;
};
}
const MAX_VERIFICATION_RETRIES = 3;
const WEB_PARTIAL_VIRTUALIZED_CONFIRMATION_DELAY_FRAMES = 1;
@@ -242,25 +230,6 @@ function shouldRequireRouteRequestConfirmation(input: {
return input.confirmationPasses < 1;
}
function getDetailedMeasurementState(
measurementState: ControllerMeasurementState,
): Record<string, unknown> {
const distanceFromBottom = Math.max(
0,
measurementState.contentHeight - (measurementState.offsetY + measurementState.viewportHeight),
);
return {
containerKey: measurementState.containerKey,
viewportWidth: measurementState.viewportWidth,
viewportHeight: measurementState.viewportHeight,
contentHeight: measurementState.contentHeight,
offsetY: measurementState.offsetY,
distanceFromBottom,
viewportMeasuredForKey: measurementState.viewportMeasuredForKey,
contentMeasuredForKey: measurementState.contentMeasuredForKey,
};
}
function createBottomAnchorControllerDriver(
input: CreateBottomAnchorControllerDriverInput,
): BottomAnchorControllerDriver {
@@ -275,7 +244,7 @@ function createBottomAnchorControllerDriver(
let stickyMeasurementRevision = 0;
let lastVerifiedStickyMeasurementRevision = 0;
const getLogContext = (extra?: Record<string, unknown>) => {
const _getLogContext = (extra?: Record<string, unknown>) => {
const measurementState = input.getMeasurementState();
const distanceFromBottom = Math.max(
0,
@@ -336,7 +305,7 @@ function createBottomAnchorControllerDriver(
pendingVerification = null;
};
const cancelPendingRequest = (reason: string) => {
const cancelPendingRequest = (_reason: string) => {
const currentRequest = pendingRequest;
if (!currentRequest) {
cancelPendingAttempt();
@@ -358,7 +327,7 @@ function createBottomAnchorControllerDriver(
});
const scheduleVerification = (attemptContext: AttemptContext, delayFramesOverride?: number) => {
const scheduledMeasurementState = input.getMeasurementState();
const _scheduledMeasurementState = input.getMeasurementState();
if (verificationHandle) {
input.cancelFrame(verificationHandle);
}
@@ -461,7 +430,7 @@ function createBottomAnchorControllerDriver(
const evaluate = (
animated: boolean,
reason:
_reason:
| "request_created"
| "viewport_change"
| "content_size_change"
@@ -660,7 +629,7 @@ export const __private__ = {
deriveBottomAnchorBlockedReason,
deriveVerificationBlockedReason,
deriveRetryDisposition,
deriveModeForLocalRequest(input: {
deriveModeForLocalRequest(_input: {
reason: BottomAnchorLocalRequest["reason"];
}): BottomAnchorMode {
return "sticky-bottom";
@@ -60,8 +60,12 @@ export function useWebElementScrollbar(
if (!element) return;
element.setAttribute("data-hide-scrollbar", "");
(element.style as any).scrollbarWidth = "none";
(element.style as any).msOverflowStyle = "none";
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).scrollbarWidth = "none";
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).msOverflowStyle = "none";
ensureHideScrollbarStyle();
function update() {
@@ -90,8 +94,12 @@ export function useWebElementScrollbar(
element.removeEventListener("scroll", update);
resizeObserver.disconnect();
element.removeAttribute("data-hide-scrollbar");
(element.style as any).scrollbarWidth = "";
(element.style as any).msOverflowStyle = "";
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).scrollbarWidth = "";
(
element.style as CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string }
).msOverflowStyle = "";
};
}, [contentRef, elementRef, enabled]);
+60 -21
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
@@ -33,8 +33,18 @@ export function VolumeMeter({
// Base dimensions
const LINE_SPACING = isCompact ? 6 : 8;
const LINE_WIDTH = isCompact ? 6 : 8;
const MAX_HEIGHT = orientation === "horizontal" ? (isCompact ? 18 : 30) : isCompact ? 32 : 50;
const MIN_HEIGHT = orientation === "horizontal" ? (isCompact ? 8 : 12) : isCompact ? 14 : 20;
let MAX_HEIGHT: number;
if (orientation === "horizontal") {
MAX_HEIGHT = isCompact ? 18 : 30;
} else {
MAX_HEIGHT = isCompact ? 32 : 50;
}
let MIN_HEIGHT: number;
if (orientation === "horizontal") {
MIN_HEIGHT = isCompact ? 8 : 12;
} else {
MIN_HEIGHT = isCompact ? 14 : 20;
}
// Create shared values for 3 dots unconditionally
const animatedVolume = useSharedValue(0);
@@ -82,7 +92,7 @@ export function VolumeMeter({
-1,
false,
);
}, [isMuted]);
}, [isMuted, line1Pulse, line2Pulse, line3Pulse]);
// Drive a single animated volume value and derive the individual bar heights
// on the UI thread instead of scheduling three independent springs per sample.
@@ -99,13 +109,20 @@ export function VolumeMeter({
}, [animatedVolume, isMuted, volume]);
const lineColor = color ?? theme.colors.foreground;
const containerHeight =
orientation === "horizontal" ? (isCompact ? 32 : 60) : isCompact ? 64 : 100;
let containerHeight: number;
if (orientation === "horizontal") {
containerHeight = isCompact ? 32 : 60;
} else {
containerHeight = isCompact ? 64 : 100;
}
// Create animated styles unconditionally at top level
const line1Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
let baseOpacity: number;
if (isMuted) baseOpacity = 0.3;
else if (isActive) baseOpacity = 0.9;
else baseOpacity = 0.5;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 1.2;
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
@@ -117,7 +134,10 @@ export function VolumeMeter({
const line2Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
let baseOpacity: number;
if (isMuted) baseOpacity = 0.3;
else if (isActive) baseOpacity = 0.9;
else baseOpacity = 0.5;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 1.05;
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
@@ -129,7 +149,10 @@ export function VolumeMeter({
const line3Style = useAnimatedStyle(() => {
const isActive = isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
let baseOpacity: number;
if (isMuted) baseOpacity = 0.3;
else if (isActive) baseOpacity = 0.9;
else baseOpacity = 0.5;
const currentVolume = isMuted ? 0 : animatedVolume.value;
const currentHeight = MIN_HEIGHT + MAX_HEIGHT * currentVolume * 0.9;
const volumeBoost = isMuted || !isActive ? 0 : currentVolume * 0.3;
@@ -139,19 +162,35 @@ export function VolumeMeter({
};
});
const containerStyle = useMemo(
() => [styles.container, { height: containerHeight }],
[containerHeight],
);
const lineBase = useMemo(
() => ({ width: LINE_WIDTH, backgroundColor: lineColor }),
[LINE_WIDTH, lineColor],
);
const spacerStyle = useMemo(() => ({ width: LINE_SPACING }), [LINE_SPACING]);
const line1CombinedStyle = useMemo(
() => [styles.line, lineBase, line1Style],
[lineBase, line1Style],
);
const line2CombinedStyle = useMemo(
() => [styles.line, lineBase, line2Style],
[lineBase, line2Style],
);
const line3CombinedStyle = useMemo(
() => [styles.line, lineBase, line3Style],
[lineBase, line3Style],
);
return (
<View style={[styles.container, { height: containerHeight }]}>
<ReanimatedAnimated.View
style={[styles.line, { width: LINE_WIDTH, backgroundColor: lineColor }, line1Style]}
/>
<View style={{ width: LINE_SPACING }} />
<ReanimatedAnimated.View
style={[styles.line, { width: LINE_WIDTH, backgroundColor: lineColor }, line2Style]}
/>
<View style={{ width: LINE_SPACING }} />
<ReanimatedAnimated.View
style={[styles.line, { width: LINE_WIDTH, backgroundColor: lineColor }, line3Style]}
/>
<View style={containerStyle}>
<ReanimatedAnimated.View style={line1CombinedStyle} />
<View style={spacerStyle} />
<ReanimatedAnimated.View style={line2CombinedStyle} />
<View style={spacerStyle} />
<ReanimatedAnimated.View style={line3CombinedStyle} />
</View>
);
}
@@ -4,20 +4,20 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export type VerticalScrollbarGeometryInput = {
export interface VerticalScrollbarGeometryInput {
viewportSize: number;
contentSize: number;
offset: number;
minHandleSize?: number;
};
}
export type VerticalScrollbarGeometry = {
export interface VerticalScrollbarGeometry {
isVisible: boolean;
maxScrollOffset: number;
handleSize: number;
handleOffset: number;
maxHandleOffset: number;
};
}
export function computeVerticalScrollbarGeometry(
input: VerticalScrollbarGeometryInput,
@@ -55,12 +55,12 @@ export function computeVerticalScrollbarGeometry(
};
}
export type ScrollOffsetFromDragDeltaInput = {
export interface ScrollOffsetFromDragDeltaInput {
startOffset: number;
dragDelta: number;
maxScrollOffset: number;
maxHandleOffset: number;
};
}
export function computeScrollOffsetFromDragDelta(input: ScrollOffsetFromDragDeltaInput): number {
if (input.maxScrollOffset <= 0 || input.maxHandleOffset <= 0) {
@@ -27,7 +27,15 @@ const HANDLE_WIDTH_TRANSITION_DURATION_MS = 240;
const HANDLE_SCROLL_VISIBILITY_MS = 1200;
const HANDLE_SCROLL_ACTIVE_MS = 110;
function readClientY(event: any): number | null {
interface PointerLikeEvent {
clientY?: number;
pageY?: number;
nativeEvent?: { clientY?: number; pageY?: number; preventDefault?: () => void };
preventDefault?: () => void;
stopPropagation?: () => void;
}
function readClientY(event: PointerLikeEvent): number | null {
const value =
event?.nativeEvent?.clientY ?? event?.clientY ?? event?.nativeEvent?.pageY ?? event?.pageY;
return typeof value === "number" ? value : null;
@@ -37,11 +45,11 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
export type ScrollbarMetrics = {
export interface ScrollbarMetrics {
offset: number;
viewportSize: number;
contentSize: number;
};
}
function areMetricsEqual(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean {
return (
@@ -107,12 +115,12 @@ export function useWebDesktopScrollbarMetrics() {
};
}
type WebDesktopScrollbarOverlayProps = {
interface WebDesktopScrollbarOverlayProps {
enabled: boolean;
metrics: ScrollbarMetrics;
onScrollToOffset: (offset: number) => void;
inverted?: boolean;
};
}
export function WebDesktopScrollbarOverlay({
enabled,
@@ -281,7 +289,7 @@ export function WebDesktopScrollbarOverlay({
});
}, [applyDragDelta]);
const startWebDrag = useCallback((event: any) => {
const startWebDrag = useCallback((event: PointerLikeEvent) => {
if (!platformIsWeb) {
return;
}
@@ -333,18 +341,12 @@ export function WebDesktopScrollbarOverlay({
};
}, [applyDragDelta, isDragging]);
if (!enabled || !geometry.isVisible) {
return null;
}
const handleVisible = isDragging || isScrollVisible || isHandleHovered;
const handleOpacity = isDragging
? HANDLE_OPACITY_DRAGGING
: isHandleHovered
? HANDLE_OPACITY_HOVERED
: isScrollVisible
? HANDLE_OPACITY_VISIBLE
: 0;
let handleOpacity: number;
if (isDragging) handleOpacity = HANDLE_OPACITY_DRAGGING;
else if (isHandleHovered) handleOpacity = HANDLE_OPACITY_HOVERED;
else if (isScrollVisible) handleOpacity = HANDLE_OPACITY_VISIBLE;
else handleOpacity = 0;
const handleWidth = isDragging || isHandleHovered ? HANDLE_WIDTH_ACTIVE : HANDLE_WIDTH_IDLE;
const handleColor = theme.colors.scrollbarHandle;
const handleCursor = isDragging ? "grabbing" : "grab";
@@ -357,26 +359,55 @@ export function WebDesktopScrollbarOverlay({
);
const handleInsetTop = Math.max(0, (thumbRegionHeight - geometry.handleSize) / 2);
const thumbRegionStyle = useMemo(
() => [
styles.thumbRegion,
{
top: 0,
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
platformIsWeb &&
({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as object),
],
[thumbRegionHeight, thumbRegionOffset, handleCursor, handleTravelDurationMs],
);
const handleStyle = useMemo(
() => [
styles.handle,
{
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
platformIsWeb &&
({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as object),
],
[handleInsetTop, geometry.handleSize, handleWidth, handleColor, handleOpacity],
);
if (!enabled || !geometry.isVisible) {
return null;
}
return (
<View style={styles.overlay} pointerEvents="box-none">
<View
style={[
styles.thumbRegion,
{
top: 0,
height: thumbRegionHeight,
transform: [{ translateY: thumbRegionOffset }],
},
platformIsWeb &&
({
cursor: handleCursor,
touchAction: "none",
userSelect: "none",
transitionProperty: "transform",
transitionDuration: `${handleTravelDurationMs}ms`,
transitionTimingFunction: "linear",
} as any),
]}
style={thumbRegionStyle}
pointerEvents={handleVisible ? "auto" : "none"}
{...(panResponder?.panHandlers ?? {})}
{...(platformIsWeb
@@ -386,28 +417,10 @@ export function WebDesktopScrollbarOverlay({
onPointerLeave: handleGrabHoverOut,
onMouseEnter: handleGrabHoverIn,
onMouseLeave: handleGrabHoverOut,
} as any)
} as object)
: null)}
>
<View
style={[
styles.handle,
{
marginTop: handleInsetTop,
height: geometry.handleSize,
width: handleWidth,
backgroundColor: handleColor,
opacity: handleOpacity,
},
platformIsWeb &&
({
transitionProperty: "opacity, width, background-color",
transitionDuration: `${HANDLE_FADE_DURATION_MS}ms, ${HANDLE_WIDTH_TRANSITION_DURATION_MS}ms, ${HANDLE_FADE_DURATION_MS}ms`,
transitionTimingFunction: "ease-out, cubic-bezier(0.22, 0.75, 0.2, 1), ease-out",
} as any),
]}
pointerEvents="none"
/>
<View style={handleStyle} pointerEvents="none" />
</View>
</View>
);
+73 -48
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -16,14 +16,14 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
import { openExternalUrl } from "@/utils/open-external-url";
import { isWeb, isNative } from "@/constants/platform";
type WelcomeAction = {
interface WelcomeAction {
key: "scan-qr" | "direct-connection" | "paste-pairing-link";
label: string;
testID: string;
primary: boolean;
icon: typeof QrCode;
onPress: () => void;
};
}
const styles = StyleSheet.create((theme) => ({
root: {
@@ -178,6 +178,30 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
[router],
);
const handleOpenPaseoSite = useCallback(() => {
void openExternalUrl("https://paseo.sh");
}, []);
const handleOpenSettings = useCallback(() => {
router.push("/settings");
}, [router]);
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile, serverId }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
},
[onHostAdded, finishOnboarding],
);
const actions: WelcomeAction[] = isWeb
? [
{
@@ -186,7 +210,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-direct-connection",
primary: true,
icon: Link2,
onPress: () => setIsDirectOpen(true),
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
@@ -194,7 +218,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
onPress: handleOpenPasteLink,
},
]
: [
@@ -204,7 +228,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-scan-qr",
primary: true,
icon: QrCode,
onPress: () => router.push("/pair-scan?source=onboarding"),
onPress: handleScanQr,
},
{
key: "direct-connection",
@@ -212,7 +236,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-direct-connection",
primary: false,
icon: Link2,
onPress: () => setIsDirectOpen(true),
onPress: handleOpenDirect,
},
{
key: "paste-pairing-link",
@@ -220,20 +244,22 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
testID: "welcome-paste-pairing-link",
primary: false,
icon: ClipboardPaste,
onPress: () => setIsPasteLinkOpen(true),
onPress: handleOpenPasteLink,
},
];
const isConnectingToSavedHosts = hosts.length > 0 && !anyOnlineServerId;
const scrollContentContainerStyle = useMemo(
() => [styles.container, { paddingBottom: theme.spacing[6] + insets.bottom }],
[theme.spacing, insets.bottom],
);
return (
<View style={styles.root}>
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.container,
{ paddingBottom: theme.spacing[6] + insets.bottom },
]}
contentContainerStyle={scrollContentContainerStyle}
showsVerticalScrollIndicator={false}
testID="welcome-screen"
>
@@ -247,10 +273,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<Text style={styles.title}>Welcome to Paseo</Text>
<Text style={styles.subtitle}>Connect your computer to get started</Text>
{isNative ? (
<Pressable
style={styles.setupLink}
onPress={() => openExternalUrl("https://paseo.sh")}
>
<Pressable style={styles.setupLink} onPress={handleOpenPaseoSite}>
<Text style={styles.setupLinkText}>paseo.sh</Text>
<ExternalLink size={14} color={theme.colors.accent} />
</Pressable>
@@ -260,34 +283,16 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
</View>
<View style={styles.actions}>
{actions.map((action) => {
const Icon = action.icon;
return (
<Pressable
key={action.key}
style={[styles.actionButton, action.primary ? styles.actionButtonPrimary : null]}
onPress={action.onPress}
testID={action.testID}
>
<Icon
size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text
style={[styles.actionText, action.primary ? styles.actionTextPrimary : null]}
>
{action.label}
</Text>
</Pressable>
);
})}
{actions.map((action) => (
<WelcomeActionButton key={action.key} action={action} />
))}
</View>
<Button
variant="ghost"
size="sm"
leftIcon={Settings}
onPress={() => router.push("/settings")}
onPress={handleOpenSettings}
style={styles.settingsButton}
testID="welcome-open-settings"
>
@@ -298,22 +303,42 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
<AddHostModal
visible={isDirectOpen}
onClose={() => setIsDirectOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={() => setIsPasteLinkOpen(false)}
onSaved={({ profile, serverId }) => {
onHostAdded?.(profile);
finishOnboarding(serverId);
}}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView>
</View>
);
}
interface WelcomeActionButtonProps {
action: WelcomeAction;
}
function WelcomeActionButton({ action }: WelcomeActionButtonProps) {
const { theme } = useUnistyles();
const Icon = action.icon;
const buttonStyle = useMemo(
() => [styles.actionButton, action.primary ? styles.actionButtonPrimary : null],
[action.primary],
);
const textStyle = useMemo(
() => [styles.actionText, action.primary ? styles.actionTextPrimary : null],
[action.primary],
);
return (
<Pressable style={buttonStyle} onPress={action.onPress} testID={action.testID}>
<Icon
size={18}
color={action.primary ? theme.colors.accentForeground : theme.colors.foreground}
/>
<Text style={textStyle}>{action.label}</Text>
</Pressable>
);
}
@@ -1,10 +1,12 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from "react";
import { Dimensions, Platform, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
@@ -80,10 +82,10 @@ export function WorkspaceHoverCard({
prHint,
isDragging,
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactNode {
// Desktop-only: skip on non-web platforms
if (Platform.OS !== "web") {
return <>{children}</>;
return children;
}
return (
@@ -219,6 +221,7 @@ function WorkspaceHoverCardContent({
measureElement(triggerRef.current).then((rect) => {
if (cancelled) return;
setTriggerRect(rect);
return;
});
return () => {
@@ -248,6 +251,19 @@ function WorkspaceHoverCardContent({
[],
);
const cardStyle = useMemo(
() => [
styles.card,
{
width: HOVER_CARD_WIDTH,
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
],
[position?.x, position?.y],
);
return (
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
@@ -260,15 +276,7 @@ function WorkspaceHoverCardContent({
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
testID="workspace-hover-card"
style={[
styles.card,
{
width: HOVER_CARD_WIDTH,
position: "absolute",
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
]}
style={cardStyle}
>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle} numberOfLines={1} testID="hover-card-workspace-name">
@@ -289,57 +297,7 @@ function WorkspaceHoverCardContent({
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<Pressable
style={({ hovered }) => [styles.checksSummaryRow, hovered && styles.listRowHovered]}
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
>
{({ hovered }) => {
const checks = prHint.checks!;
const failed = checks.filter((c) => c.status === "failure").length;
const pending = checks.filter((c) => c.status === "pending").length;
let badgeColor: string;
let badgeLabel: string;
if (failed > 0) {
badgeColor = theme.colors.palette.red[500];
badgeLabel = `${failed} failed`;
} else if (pending > 0) {
badgeColor = theme.colors.palette.amber[500];
badgeLabel = `${pending} running`;
} else {
badgeColor = theme.colors.palette.green[500];
badgeLabel = `${checks.length} passed`;
}
const iconColor = hovered
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<>
{hovered ? (
<ExternalLink size={12} color={iconColor} />
) : (
<GitHubIcon size={12} color={iconColor} />
)}
<Text
style={[
styles.checksSummaryLabel,
hovered && styles.checksSummaryLabelHovered,
]}
>
Checks
</Text>
<View style={styles.checksSummaryCounts}>
<View style={[styles.checksDot, { backgroundColor: badgeColor }]} />
<Text style={[styles.checksStatusText, { color: badgeColor }]}>
{badgeLabel}
</Text>
</View>
</>
);
}}
</Pressable>
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} theme={theme} />
</>
) : null}
</Animated.View>
@@ -348,6 +306,94 @@ function WorkspaceHoverCardContent({
);
}
function ChecksSummaryContent({
checks,
theme,
hovered,
}: {
checks: NonNullable<PrHint["checks"]>;
theme: ReturnType<typeof useUnistyles>["theme"];
hovered: boolean;
}) {
const failed = checks.filter((c) => c.status === "failure").length;
const pending = checks.filter((c) => c.status === "pending").length;
let badgeColor: string;
let badgeLabel: string;
if (failed > 0) {
badgeColor = theme.colors.palette.red[500];
badgeLabel = `${failed} failed`;
} else if (pending > 0) {
badgeColor = theme.colors.palette.amber[500];
badgeLabel = `${pending} running`;
} else {
badgeColor = theme.colors.palette.green[500];
badgeLabel = `${checks.length} passed`;
}
const iconColor = hovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const labelStyle = useMemo(
() => [styles.checksSummaryLabel, hovered && styles.checksSummaryLabelHovered],
[hovered],
);
const dotStyle = useMemo(() => [styles.checksDot, { backgroundColor: badgeColor }], [badgeColor]);
const statusTextStyle = useMemo(
() => [styles.checksStatusText, { color: badgeColor }],
[badgeColor],
);
return (
<>
{hovered ? (
<ExternalLink size={12} color={iconColor} />
) : (
<GitHubIcon size={12} color={iconColor} />
)}
<Text style={labelStyle}>Checks</Text>
<View style={styles.checksSummaryCounts}>
<View style={dotStyle} />
<Text style={statusTextStyle}>{badgeLabel}</Text>
</View>
</>
);
}
function ChecksSummaryPressable({
checks,
url,
theme,
}: {
checks: NonNullable<PrHint["checks"]>;
url: string;
theme: ReturnType<typeof useUnistyles>["theme"];
}) {
const handlePress = useCallback(() => {
void openExternalUrl(`${url}/checks`);
}, [url]);
const pressableStyle = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => [
styles.checksSummaryRow,
Boolean(hovered) && styles.listRowHovered,
],
[],
);
const renderChildren = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
<ChecksSummaryContent checks={checks} theme={theme} hovered={Boolean(hovered)} />
),
[checks, theme],
);
return (
<Pressable style={pressableStyle} onPress={handlePress}>
{renderChildren}
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
portalOverlay: {
position: "absolute",
@@ -14,6 +14,11 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import type {
CreateAgentRequestOptions,
CreatePaseoWorktreeInput,
DaemonClient,
} from "@server/client/daemon-client";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
@@ -26,6 +31,114 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
return `data:${icon.mimeType};base64,${icon.data}`;
}
const SNAP_POINTS: string[] = ["82%", "94%"];
function resolveWorkspaceTitle({
workspace,
displayName,
sourceDirectory,
}: {
workspace: { name?: string | null; projectDisplayName?: string | null } | null;
displayName: string;
sourceDirectory: string;
}): string {
return (
workspace?.name ||
workspace?.projectDisplayName ||
displayName ||
sourceDirectory.split(/[\\/]/).findLast(Boolean) ||
sourceDirectory
);
}
function buildChatDraftComposerArgs({
serverId,
isConnected,
workspaceDirectory,
sourceDirectory,
pendingWorkspaceSetup,
}: {
serverId: string;
isConnected: boolean;
workspaceDirectory: string | undefined;
sourceDirectory: string;
pendingWorkspaceSetup: { creationMethod: string } | null;
}) {
return {
initialServerId: serverId || null,
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspaceDirectory || sourceDirectory || undefined,
};
}
type WorkspaceCreationAttachments = NonNullable<CreatePaseoWorktreeInput["attachments"]>;
async function callWorkspaceCreation({
creationMethod,
connectedClient,
input,
wirePayload,
}: {
creationMethod: "create_worktree" | "open_project";
connectedClient: DaemonClient;
input: { cwd: string };
wirePayload: { attachments: WorkspaceCreationAttachments };
}) {
if (creationMethod === "create_worktree") {
return connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
});
}
return connectedClient.openProject(input.cwd);
}
function failureMessageForCreationMethod(method: "create_worktree" | "open_project") {
return method === "create_worktree" ? "Failed to create worktree" : "Failed to open project";
}
function buildCreateAgentOptions({
composerState,
text,
attachments,
encodedImages,
workspaceDirectory,
workspaceId,
provider,
}: {
composerState: {
modeOptions: { id: string }[];
selectedMode: string;
effectiveModelId: string | null;
effectiveThinkingOptionId: string | null;
};
text: string;
attachments: NonNullable<CreateAgentRequestOptions["attachments"]>;
encodedImages: NonNullable<CreateAgentRequestOptions["images"]> | null;
workspaceDirectory: string;
workspaceId: string;
provider: CreateAgentRequestOptions["provider"];
}): CreateAgentRequestOptions {
return {
provider,
cwd: workspaceDirectory,
workspaceId,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}),
...(text.trim() ? { initialPrompt: text.trim() } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
...(attachments.length > 0 ? { attachments } : {}),
};
}
export function WorkspaceSetupDialog() {
const { theme } = useUnistyles();
const toast = useToast();
@@ -49,15 +162,13 @@ export function WorkspaceSetupDialog() {
const chatDraft = useAgentInputDraft({
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
initialCwd: sourceDirectory,
composer: {
initialServerId: serverId || null,
initialValues: workspace?.workspaceDirectory
? { workingDir: workspace.workspaceDirectory }
: undefined,
isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspace?.workspaceDirectory || sourceDirectory || undefined,
},
composer: buildChatDraftComposerArgs({
serverId,
isConnected,
workspaceDirectory: workspace?.workspaceDirectory,
sourceDirectory,
pendingWorkspaceSetup,
}),
});
const composerState = chatDraft.composerState;
if (!composerState && pendingWorkspaceSetup) {
@@ -119,23 +230,16 @@ export function WorkspaceSetupDialog() {
const connectedClient = withConnectedClient();
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const payload =
pendingWorkspaceSetup.creationMethod === "create_worktree"
? await connectedClient.createPaseoWorktree({
cwd: input.cwd,
worktreeSlug: createNameId(),
...(wirePayload.attachments.length > 0
? { attachments: wirePayload.attachments }
: {}),
})
: await connectedClient.openProject(input.cwd);
const payload = await callWorkspaceCreation({
creationMethod: pendingWorkspaceSetup.creationMethod,
connectedClient,
input,
wirePayload,
});
if (payload.error || !payload.workspace) {
throw new Error(
payload.error ??
(pendingWorkspaceSetup.creationMethod === "create_worktree"
? "Failed to create worktree"
: "Failed to open project"),
payload.error ?? failureMessageForCreationMethod(pendingWorkspaceSetup.creationMethod),
);
}
@@ -174,7 +278,7 @@ export function WorkspaceSetupDialog() {
try {
setPendingAction("chat");
setErrorMessage(null);
const workspace = await ensureWorkspace({ cwd, attachments });
const ensuredWorkspace = await ensureWorkspace({ cwd, attachments });
const connectedClient = withConnectedClient();
if (!composerState) {
throw new Error("Workspace setup composer state is required");
@@ -186,23 +290,19 @@ export function WorkspaceSetupDialog() {
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const encodedImages = await encodeImages(wirePayload.images);
const workspaceDirectory = requireWorkspaceExecutionAuthority({
workspace,
workspace: ensuredWorkspace,
}).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: workspace.id,
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
...(composerState.effectiveThinkingOptionId
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
: {}),
...(text.trim() ? { initialPrompt: text.trim() } : {}),
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
});
const agent = await connectedClient.createAgent(
buildCreateAgentOptions({
composerState,
text,
attachments: wirePayload.attachments,
encodedImages: encodedImages ?? null,
workspaceDirectory,
workspaceId: ensuredWorkspace.id,
provider: composerState.selectedProvider,
}),
);
if (!getIsStillActive()) {
return;
@@ -213,7 +313,7 @@ export function WorkspaceSetupDialog() {
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
return next;
});
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id });
} catch (error) {
const message = toErrorMessage(error);
setErrorMessage(message);
@@ -236,12 +336,7 @@ export function WorkspaceSetupDialog() {
],
);
const workspaceTitle =
workspace?.name ||
workspace?.projectDisplayName ||
displayName ||
sourceDirectory.split(/[\\/]/).filter(Boolean).pop() ||
sourceDirectory;
const workspaceTitle = resolveWorkspaceTitle({ workspace, displayName, sourceDirectory });
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle);
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
@@ -258,33 +353,47 @@ export function WorkspaceSetupDialog() {
() => ({ backgroundColor: theme.colors.surface2 }),
[theme.colors.surface2],
);
const iconSource = useMemo(() => (iconDataUri ? { uri: iconDataUri } : null), [iconDataUri]);
const statusControlsWithDisabled = useMemo(
() =>
composerState
? {
...composerState.statusControls,
disabled: pendingAction !== null,
}
: undefined,
[composerState, pendingAction],
);
const subtitleContent = useMemo(
() => (
<View style={styles.subtitleRow}>
{iconSource ? (
<Image source={iconSource} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
<Text style={styles.projectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
),
[iconSource, placeholderInitial, workspaceTitle],
);
if (!pendingWorkspaceSetup || !sourceDirectory) {
return null;
}
const subtitleContent = (
<View style={styles.subtitleRow}>
{iconDataUri ? (
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}
<Text style={styles.projectTitle} numberOfLines={1}>
{workspaceTitle}
</Text>
</View>
);
return (
<AdaptiveModalSheet
title="Create workspace"
subtitle={subtitleContent}
visible={true}
onClose={handleClose}
snapPoints={["82%", "94%"]}
snapPoints={SNAP_POINTS}
testID="workspace-setup-dialog"
desktopMaxWidth={640}
onFilesDropped={handleFilesDropped}
@@ -305,14 +414,7 @@ export function WorkspaceSetupDialog() {
clearDraft={chatDraft.clear}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
statusControls={
composerState
? {
...composerState.statusControls,
disabled: pendingAction !== null,
}
: undefined
}
statusControls={statusControlsWithDisabled}
inputWrapperStyle={composerInputWrapperStyle}
onAddImages={handleAddImagesCallback}
/>