mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 09:12:44 +00:00
fix(app): keep composer layout stable across chat and workspace screens (#4973)
* fix(app): dock the New workspace composer like a chat on phones Only the composer is bounded by the viewport; the setup fields sit above it in a bottom-anchored stack that moves up as the draft grows. The keyboard reservation is retained on every screen, so the composer keeps one height across keyboard transitions. Removes the per-screen reserve policy. * fix(app): share composer docking across mobile hosts Extract chat's retained capacity and platform keyboard motion so chat, workspace drafts, and New workspace share one sizing and dismissal boundary. Keep the native drivers and settled portal reconciliation unchanged. * fix(app): preserve overlays in centered composer docks Honor the optional overlay child in both layouts so changing dock placement does not silently discard part of its content. * test(app): resolve keyboard controller through its web entry The shared keyboard entry point makes stream tests load the provider. Keep its real ESM dependencies inside the Vitest resolver so CommonJS does not bypass the React Native web alias and parse native Flow sources. * fix(app): keep native chat scrolling responsive Place keyboard dismissal behind scrollable content so an idle Android Fabric JS responder cannot intercept native drags. Keep decorative form padding transparent to iOS hit testing and exercise idle native scrolling after submission from every composer host.
This commit is contained in:
+45
-15
@@ -137,13 +137,38 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
|
||||
`composer/dock` owns the stationary viewport, bottom-anchored surface, composer
|
||||
capacity, and keyboard motion for chat, workspace draft tabs, and New workspace.
|
||||
Render `<ComposerDock>{contentAbove}{composer}</ComposerDock>` below the header.
|
||||
The optional third child is an overlay that moves with the surface. New workspace
|
||||
uses `centered` for its existing desktop/tablet form. Hosts never reserve keyboard
|
||||
space or translate the composer themselves. Put the dismiss surface behind content
|
||||
as a sibling, with `pointerEvents="box-none"` on its content wrapper. A Pressable
|
||||
ancestor takes the JS responder on idle Android Fabric and intercepts the stream's
|
||||
MOVE events before its native scroll view reaches touch slop. Streaming can hide
|
||||
this failure by delaying the responder grant. Native scroll views retain their own
|
||||
tap-to-dismiss behavior; unclaimed empty-space taps reach the dismiss sibling.
|
||||
On iOS, decorative form containers also need `box-none` (and inert titles/spacers
|
||||
need `none`); otherwise their empty padding blocks hit testing of that sibling.
|
||||
The shared screen header uses the same dismiss surface. Keep the
|
||||
header escape available when a capped draft clips all content above it, since
|
||||
iPhone has no system keyboard-dismiss button. Child controls keep their touches,
|
||||
and the stream keeps its existing flick-to-dismiss gesture.
|
||||
|
||||
The dock's private viewport and capacity model are extracted from chat. The
|
||||
app-wide `@/keyboard/shift` module owns shared motion for the dock, portals, streams,
|
||||
terminal, explorer, and dialogs. Import only its public entry point: the provider,
|
||||
shift hooks, shift style, translate view, settled stream inset, and explorer padding
|
||||
policy. Its contexts and reconciliation stay private. Keep `KeyboardShiftProvider`
|
||||
at its root mount point outside the portal host when changing dock hosts.
|
||||
|
||||
`KeyboardTranslateView` owns visual keyboard motion inside `@/keyboard/shift`. Android uses the
|
||||
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
|
||||
signal because the stock iOS Reanimated value changes at move start, while
|
||||
writing a replacement Reanimated value every frame interrupts UIKit's hide
|
||||
animation. Keep this platform choice inside `KeyboardTranslateView`.
|
||||
animation. Keep this platform choice inside the keyboard module's private platform-specific translate files.
|
||||
|
||||
`KeyboardDock` always keeps the chat surface at full height and wraps it in
|
||||
`ComposerDock` always keeps the surface at full height and wraps it in
|
||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
||||
slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
@@ -158,8 +183,8 @@ content inset on the inverted stream list. Update that inset only when keyboard
|
||||
motion settles; never drive it per frame.
|
||||
|
||||
The translated dock's height is not the composer's available height. Bound the
|
||||
composer against the stationary space below the header through
|
||||
`composer/viewport`, including its controls and attachments. Reserve the
|
||||
composer against the stationary space below the header inside
|
||||
`composer/dock`, including its controls and attachments. Reserve the
|
||||
keyboard destination at move start on the UI thread. Waiting for a settled JS
|
||||
update lets a long draft disappear behind the header during opening. This is a
|
||||
boundary-time layout change, not an animated layout prop. Keep the input's
|
||||
@@ -168,17 +193,22 @@ native intrinsic sizing and internal scrolling. Keep the editing input's
|
||||
the JS text whenever its frame changed, so a stale prop inflates or collapses
|
||||
the input (see `packages/app/src/components/ui/text-input/text-input.native.tsx`).
|
||||
|
||||
What the closed keyboard leaves behind is the viewport's `keyboardReserve`
|
||||
policy. A docked chat composer retains its last reservation so an unchanged
|
||||
capped draft does not expand over the stream; subsequent keyboard openings
|
||||
recalculate it. New workspace releases it: its setup fields scroll inside the
|
||||
same constrained space while the keyboard is open, so keeping the reservation
|
||||
after it closes leaves the form clipped and the composer small with the screen
|
||||
empty above them. The Android regression must check both the header and IME
|
||||
boundaries; checking only the bottom controls allowed this regression through
|
||||
previously.
|
||||
Closing the keyboard moves the composer; it does not enlarge its capacity. The
|
||||
viewport keeps the last keyboard reservation until the next opening replaces
|
||||
it, so a capped draft keeps one height through every keyboard transition
|
||||
instead of expanding on close and shrinking again on open.
|
||||
|
||||
Move the stream and composer together through `KeyboardDock`. Do not translate
|
||||
Keep setup fields out of the composer's shrink chain. New workspace supplies its
|
||||
setup fields as the first dock child so they move up under the header as the draft
|
||||
grows. Putting the fields and composer in one bounded, shrinkable column made
|
||||
Yoga split the shortage between them: rows vanished behind the composer while
|
||||
the input lost lines at the same time. Keep that separation inside the dock,
|
||||
shared by every phone host. The centered tablet form caps the complete block
|
||||
for header clearance, but reserves the composer's own capped height before
|
||||
shrinking the setup scroll view. The form must not take editing space from the
|
||||
composer.
|
||||
|
||||
Move the stream and composer together through `ComposerDock`. Do not translate
|
||||
them independently.
|
||||
|
||||
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
|
||||
|
||||
+22
-2
@@ -47,13 +47,33 @@ focused input identity and IME hide/show events. It saves screenshots and logs u
|
||||
`.dev/agent-device-artifacts/terminal-keyboard-android`. Set `PASEO_TERMINAL_KEYBOARD_APP_ID=sh.paseo`
|
||||
to test an installed production build. It never submits a chat message.
|
||||
|
||||
`npm run test:e2e:composer-keyboard:android` covers composer growth in chat and
|
||||
New workspace, plus keyboard and control interactions. Configure its daemon,
|
||||
`npm run test:e2e:composer-keyboard:android` preserves the chat control and
|
||||
keyboard regression flow, then runs the same growth, retained-height, bounds, and
|
||||
hold-to-delete and background-tap dismissal scenario on chat, a workspace draft
|
||||
tab, and New workspace. It checks empty-content taps and header taps at the cap
|
||||
without changing the input height. Each
|
||||
host also opens command/file autocomplete, the model selector, the attachment
|
||||
menu, and the forge picker from both keyboard states. It then submits a long
|
||||
fixture from each host and checks native stream displacement twice: after the
|
||||
response settles, and after returning to the bottom and letting the scrollbar
|
||||
fade. The image comparison excludes the scrollbar; keyboard dismissal alone
|
||||
does not prove that native scrolling receives touches.
|
||||
Artifacts are grouped by host under
|
||||
`.dev/agent-device-artifacts/composer-keyboard-android`. Configure its daemon,
|
||||
Metro, and device through the `PASEO_COMPOSER_KEYBOARD_*` variables in
|
||||
`packages/app/e2e/mobile/composer-keyboard/android.sh`. Use a software keyboard;
|
||||
the headless input helper cannot verify the
|
||||
[visible composer constraints](floating-panels.md#gotcha-3--keyboard-layout-and-portal-anchors).
|
||||
|
||||
On a Play-image emulator, disable Gmail and Calendar first. Their welcome
|
||||
screens launch on their own, take input focus, and the run fails with
|
||||
`IME did not become visible` while the app still looks focused:
|
||||
|
||||
```bash
|
||||
adb shell pm disable-user --user 0 com.google.android.gm
|
||||
adb shell pm disable-user --user 0 com.google.android.calendar
|
||||
```
|
||||
|
||||
When replay diverges, read its ranked selector suggestions. Edit the script deliberately and rerun it from the beginning. `--update` is retained for compatibility but no longer rewrites scripts.
|
||||
|
||||
## Maestro compatibility
|
||||
|
||||
@@ -39,19 +39,22 @@ capture_screen() {
|
||||
|
||||
snapshot_json() {
|
||||
local output_path="$1"
|
||||
ad snapshot -i --json >"${output_path}"
|
||||
local detail="${2:---raw}"
|
||||
# A transient missing application window is a failed capture, never evidence.
|
||||
for attempt in 1 2 3; do
|
||||
if ad snapshot "${detail}" --json >"${output_path}"; then return; fi
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
capture_ui_xml() {
|
||||
local output_path="$1"
|
||||
local device_path="/sdcard/paseo-composer-keyboard-window.xml"
|
||||
# Android exposes one UI Automation connection at a time. Release the
|
||||
# persistent agent-device snapshot helper before asking uiautomator for the
|
||||
# app and IME windows; agent-device reconnects it on the next interaction.
|
||||
adb shell am force-stop com.callstack.agentdevice.snapshothelper
|
||||
sleep 0.2
|
||||
adb shell uiautomator dump "${device_path}" >/dev/null
|
||||
adb pull "${device_path}" "${output_path}" >/dev/null
|
||||
# The helper reads current accessibility bounds without uiautomator's global
|
||||
# idle wait (retained screens can keep that wait busy). It leaves Gboard open.
|
||||
# Serialize those same physical bounds for the existing XML assertions.
|
||||
snapshot_json "${output_path}.json" --raw
|
||||
node "${ASSERT}" snapshot-xml "${output_path}.json" "${output_path}"
|
||||
}
|
||||
|
||||
clear_input() {
|
||||
@@ -69,7 +72,8 @@ wait_for_ime() {
|
||||
local expected="$1"
|
||||
local previous_top=""
|
||||
local stable_count=0
|
||||
for _ in $(seq 1 30); do
|
||||
# Gboard's first show after an IME switch can take several seconds on a loaded host.
|
||||
for _ in $(seq 1 100); do
|
||||
local window_state
|
||||
window_state="$(adb shell dumpsys window)"
|
||||
if [[ "${expected}" == "true" ]]; then
|
||||
@@ -87,6 +91,10 @@ wait_for_ime() {
|
||||
fi
|
||||
fi
|
||||
elif ! printf '%s' "${window_state}" | rg -q "type=ime .*visible=true"; then
|
||||
# The window stops reporting visible when the hide animation starts. Android
|
||||
# drops a show request made while it is still running, so let it finish
|
||||
# before the next tap.
|
||||
sleep 0.75
|
||||
return
|
||||
fi
|
||||
sleep 0.1
|
||||
@@ -100,20 +108,26 @@ ime_is_visible() {
|
||||
}
|
||||
|
||||
open_gboard() {
|
||||
local x="$1"
|
||||
local y="$2"
|
||||
local x="$1" y="$2" height
|
||||
adb shell ime set "${GBOARD_IME}" >/dev/null
|
||||
sleep 1
|
||||
if ime_is_visible; then
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
fi
|
||||
# IME switching can restore focus before accessibility reports its new frame.
|
||||
# Locate the editor for each tap instead of translating a previous snapshot:
|
||||
# an old editor position can now be a background that correctly dismisses it.
|
||||
snapshot_json "${ARTIFACTS_DIR}/keyboard-target.json" --raw
|
||||
read -r x y height < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/keyboard-target.json" editable right)
|
||||
adb shell input tap "${x}" "${y}"
|
||||
wait_for_ime true
|
||||
local keyboard_shift
|
||||
keyboard_shift="$(read_keyboard_shift)"
|
||||
adb shell input tap "${x}" "$((y - keyboard_shift))"
|
||||
snapshot_json "${ARTIFACTS_DIR}/keyboard-target-open.json" --raw
|
||||
read -r x y height < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/keyboard-target-open.json" editable right)
|
||||
adb shell input tap "${x}" "${y}"
|
||||
}
|
||||
|
||||
|
||||
read_keyboard_shift() {
|
||||
local ime_top
|
||||
local navigation_top
|
||||
@@ -158,6 +172,7 @@ adb shell am start \
|
||||
sleep 3
|
||||
|
||||
# Create the fixture while the client is connected so its directory replica observes the insert.
|
||||
ad wait 'id="message-input-root"' 90000
|
||||
run_json="$(env -u PASEO_CALLER_AGENT_ID -u PASEO_AGENT_ID -u PASEO_WORKSPACE_ID \
|
||||
npm run --silent cli -- run "Exercise the composer keyboard invariant flow" \
|
||||
--background \
|
||||
@@ -375,6 +390,26 @@ node "${ASSERT}" same-region \
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "x" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-short-draft.json"
|
||||
read -r new_input_x new_input_y _ < <(
|
||||
node "${ASSERT}" rect "${ARTIFACTS_DIR}/new-workspace-short-draft.json" "editable"
|
||||
)
|
||||
open_gboard "${new_input_x}" "${new_input_y}"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml"
|
||||
# Typing more lines moves the setup fields up with the composer instead of
|
||||
# hiding their bottom rows behind it.
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input keyevent 66 66 66
|
||||
adb shell input text grown
|
||||
sleep 1
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.png"
|
||||
node "${ASSERT}" xml-fields-follow-growth \
|
||||
"${ARTIFACTS_DIR}/new-workspace-short-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-grown-draft-keyboard-open.xml"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft.json"
|
||||
@@ -392,13 +427,248 @@ node "${ASSERT}" xml-composer-contained \
|
||||
"$(read_ime_top)" \
|
||||
"${display_density}"
|
||||
|
||||
# Dismissing the keyboard moves the composer down at its editing height and
|
||||
# brings the setup fields back fully visible above it.
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
snapshot_json "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.json"
|
||||
capture_ui_xml "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
capture_screen "${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.png"
|
||||
node "${ASSERT}" same-input-height \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft.json" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.json"
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-open.xml" \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
node "${ASSERT}" xml-fields-above-composer \
|
||||
"${ARTIFACTS_DIR}/new-workspace-long-draft-keyboard-closed.xml"
|
||||
|
||||
# All hosts run the same scenario; only navigation differs.
|
||||
# For the shared scenario, tap the editor's blank right edge once. Tapping
|
||||
# text again after opening invokes Android's spelling menu for long drafts.
|
||||
open_editor_keyboard() {
|
||||
local x="$1" y="$2"
|
||||
local current_ime
|
||||
current_ime="$(adb shell settings get secure default_input_method)"
|
||||
if [[ "${current_ime}" != "${GBOARD_IME}" ]]; then
|
||||
adb shell ime set "${GBOARD_IME}" >/dev/null
|
||||
sleep 1
|
||||
fi
|
||||
if ! ime_is_visible; then adb shell input tap "${x}" "${y}"; fi
|
||||
wait_for_ime true
|
||||
}
|
||||
|
||||
prepare_composer() {
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
wait_for_ime false
|
||||
clear_input
|
||||
wait_for_ime false
|
||||
snapshot_json "${ARTIFACTS_DIR}/overlay-input.json"
|
||||
read -r overlay_x overlay_y overlay_h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/overlay-input.json" editable right)
|
||||
if [[ "$1" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi
|
||||
}
|
||||
press_composer_control() {
|
||||
local x y h
|
||||
snapshot_json "${ARTIFACTS_DIR}/overlay-input.json"
|
||||
read -r x y h < <(node "${ASSERT}" rect "${ARTIFACTS_DIR}/overlay-input.json" "$1")
|
||||
adb shell input tap "${x}" "${y}"
|
||||
}
|
||||
|
||||
run_host_overlays() {
|
||||
local host="$1"
|
||||
for mode in closed open; do
|
||||
for query in / @README; do
|
||||
prepare_composer closed
|
||||
adb shell input text "${query}"
|
||||
if [[ "${mode}" == open ]]; then open_editor_keyboard "${overlay_x}" "${overlay_y}"; fi
|
||||
ad wait 'id="composer-autocomplete-popover"' 10000
|
||||
local kind=commands
|
||||
if [[ "${query}" == @* ]]; then kind=files; fi
|
||||
capture_screen "${ARTIFACTS_DIR}/${host}-${kind}-${mode}.png"
|
||||
clear_focused_input_with_adb
|
||||
echo "PASS host=${host}: ${kind} popover keyboard=${mode}"
|
||||
done
|
||||
prepare_composer "${mode}"
|
||||
press_composer_control combined-model-selector
|
||||
ad wait 'id="compact-provider-list"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/${host}-model-${mode}.png"
|
||||
ad press 'label="Close"' --settle
|
||||
echo "PASS host=${host}: model selector keyboard=${mode}"
|
||||
prepare_composer "${mode}"
|
||||
press_composer_control message-input-attach-button
|
||||
ad wait 'id="message-input-attachment-menu-content"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/${host}-attachments-${mode}.png"
|
||||
ad press 'id="message-input-attachment-menu-item-github"' --settle
|
||||
ad wait 'text="Attach issue or PR"' 10000
|
||||
capture_screen "${ARTIFACTS_DIR}/${host}-forge-${mode}.png"
|
||||
adb shell input tap 540 400
|
||||
ad wait 'id="message-input-attach-button"' 10000
|
||||
echo "PASS host=${host}: attachment menu and forge picker keyboard=${mode}"
|
||||
done
|
||||
}
|
||||
|
||||
run_host_scroll() {
|
||||
local host="$1"
|
||||
local prefix="${ARTIFACTS_DIR}/${host}-native-scroll"
|
||||
prepare_composer closed
|
||||
if [[ "${host}" == new-workspace ]]; then
|
||||
ad press 'id="workspace-create-isolation-trigger"' --settle
|
||||
ad press 'text="Local"' --settle
|
||||
fi
|
||||
# Submit through each real host; draft/workspace creation then mounts its chat.
|
||||
# A selectable mock model keeps this test independent of provider credentials.
|
||||
press_composer_control combined-model-selector
|
||||
ad wait 'id="model-search-all-input"' 10000
|
||||
ad fill 'id="model-search-all-input"' 'Ten second stream'
|
||||
ad wait 'id="model-row-mock-ten-second-stream"' 10000
|
||||
ad press 'id="model-row-mock-ten-second-stream"' --settle
|
||||
ad press 'label="Close"' --settle
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
wait_for_ime false
|
||||
local scroll_message
|
||||
scroll_message="$(node -e 'process.stdout.write(Array.from({length: 50}, (_, i) => `Native scroll fixture ${i}: distinct history row after submitting from a dock host.`).join("\n"))')"
|
||||
ad fill 'editable=true' "${scroll_message}"
|
||||
if [[ "${host}" == new-workspace ]]; then
|
||||
ad press 'id="workspace-create-submit"' --settle
|
||||
else
|
||||
ad press 'label="Send message"' --settle
|
||||
fi
|
||||
ad wait 'id="agent-chat-scroll"' 45000
|
||||
sleep 15
|
||||
ad wait 'text="(end of synthetic stream)"' 45000
|
||||
if ime_is_visible; then adb shell input keyevent BACK; fi
|
||||
wait_for_ime false
|
||||
# Streaming/re-renders can mask responder interception. Exercise idle JS after
|
||||
# submit, and again after returning to the bottom and letting the scrollbar fade.
|
||||
sleep 5
|
||||
capture_screen "${prefix}-submitted.png"
|
||||
adb shell input swipe 540 800 540 1400 250
|
||||
sleep 2
|
||||
capture_screen "${prefix}-after-submit-swipe.png"
|
||||
node "${ASSERT}" stream-moved "${prefix}-submitted.png" "${prefix}-after-submit-swipe.png"
|
||||
for _ in 1 2 3 4 5 6; do adb shell input swipe 540 1400 540 600 120; done
|
||||
sleep 5
|
||||
capture_screen "${prefix}-bottom-idle.png"
|
||||
adb shell input swipe 540 800 540 1400 250
|
||||
sleep 2
|
||||
capture_screen "${prefix}-after-idle-swipe.png"
|
||||
node "${ASSERT}" stream-moved "${prefix}-bottom-idle.png" "${prefix}-after-idle-swipe.png"
|
||||
echo "PASS host=${host}: native stream swipe after submit and after idle at bottom"
|
||||
}
|
||||
|
||||
run_host_scenario() {
|
||||
local host="$1"
|
||||
local prefix="${ARTIFACTS_DIR}/${host}"
|
||||
echo "BEGIN host=${host}"
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
wait_for_ime false
|
||||
clear_input
|
||||
ad keyboard dismiss || true
|
||||
snapshot_json "${prefix}-empty.json"
|
||||
capture_screen "${prefix}-empty.png"
|
||||
local x y height
|
||||
read -r x y height < <(node "${ASSERT}" rect "${prefix}-empty.json" editable right)
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
capture_ui_xml "${prefix}-empty-open.xml"
|
||||
capture_screen "${prefix}-empty-open.png"
|
||||
node "${ASSERT}" same-input-height "${prefix}-empty.json" "${prefix}-empty-open.xml"
|
||||
# The left gutter above a one-line composer is empty on every host.
|
||||
local dismiss_x dismiss_y
|
||||
read -r dismiss_x dismiss_y < <(node "${ASSERT}" xml-background-point "${prefix}-empty-open.xml" content)
|
||||
adb shell input tap "${dismiss_x}" "${dismiss_y}"
|
||||
wait_for_ime false
|
||||
capture_ui_xml "${prefix}-content-dismissed.xml"
|
||||
node "${ASSERT}" same-input-height "${prefix}-empty-open.xml" "${prefix}-content-dismissed.xml"
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
adb shell input text short
|
||||
capture_ui_xml "${prefix}-short.xml"
|
||||
adb shell input keyevent 66 66 66
|
||||
adb shell input text grown
|
||||
capture_ui_xml "${prefix}-grown.xml"
|
||||
capture_screen "${prefix}-grown.png"
|
||||
node "${ASSERT}" xml-content-follow-growth "${prefix}-short.xml" "${prefix}-grown.xml"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
wait_for_ime false
|
||||
ad fill 'editable=true' "${LONG_MESSAGE}" --settle
|
||||
snapshot_json "${prefix}-long.json"
|
||||
read -r x y height < <(node "${ASSERT}" rect "${prefix}-long.json" editable right)
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
capture_ui_xml "${prefix}-long-open.xml"
|
||||
capture_screen "${prefix}-long-open.png"
|
||||
node "${ASSERT}" xml-composer-contained "${prefix}-long-open.xml" "$(read_ime_top)" "${display_density}"
|
||||
# At the cap the header remains reachable even when all content is clipped.
|
||||
read -r dismiss_x dismiss_y < <(node "${ASSERT}" xml-background-point "${prefix}-long-open.xml" header)
|
||||
adb shell input tap "${dismiss_x}" "${dismiss_y}"
|
||||
wait_for_ime false
|
||||
capture_ui_xml "${prefix}-header-dismissed.xml"
|
||||
node "${ASSERT}" same-input-height "${prefix}-long-open.xml" "${prefix}-header-dismissed.xml"
|
||||
snapshot_json "${prefix}-header-dismissed.json"
|
||||
read -r x y height < <(node "${ASSERT}" rect "${prefix}-header-dismissed.json" editable right)
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
capture_ui_xml "${prefix}-long-closed.xml"
|
||||
capture_screen "${prefix}-long-closed.png"
|
||||
node "${ASSERT}" same-input-height "${prefix}-long-open.xml" "${prefix}-long-closed.xml"
|
||||
local navigation_top
|
||||
navigation_top="$(adb shell dumpsys window | sed -n 's/.*type=navigationBars frame=\[0,\([0-9][0-9]*\)\].*/\1/p' | head -1)"
|
||||
node "${ASSERT}" xml-composer-contained "${prefix}-long-closed.xml" "${navigation_top}" "${display_density}"
|
||||
snapshot_json "${prefix}-long-closed.json"
|
||||
read -r x y height < <(node "${ASSERT}" rect "${prefix}-long-closed.json" editable right)
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
capture_ui_xml "${prefix}-long-reopened.xml"
|
||||
capture_screen "${prefix}-long-reopened.png"
|
||||
node "${ASSERT}" same-input-height "${prefix}-long-open.xml" "${prefix}-long-reopened.xml"
|
||||
|
||||
adb shell ime set "${HELPER_IME}" >/dev/null
|
||||
wait_for_ime false
|
||||
ad fill 'editable=true' "${BLANK_LINE_DRAFT}" --settle
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
adb shell input keycombination 113 122
|
||||
adb shell input keyevent 20 20 20 20 20 20 20 20
|
||||
local delete_y="$(($(read_ime_top) + 507))"
|
||||
adb shell input swipe "${backspace_x}" "${delete_y}" "${backspace_x}" "${delete_y}" 8000
|
||||
adb shell input keycombination 113 123
|
||||
adb shell input swipe "${backspace_x}" "${delete_y}" "${backspace_x}" "${delete_y}" 12000
|
||||
capture_ui_xml "${prefix}-cleared.xml"
|
||||
capture_screen "${prefix}-cleared.png"
|
||||
node "${ASSERT}" same-input-height "${prefix}-empty-open.xml" "${prefix}-cleared.xml"
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
snapshot_json "${prefix}-cleared-closed.json"
|
||||
read -r x y height < <(node "${ASSERT}" rect "${prefix}-cleared-closed.json" editable right)
|
||||
capture_screen "${prefix}-stall-baseline.png"
|
||||
capture_screen "${prefix}-cleared-closed.png"
|
||||
open_editor_keyboard "${x}" "${y}"
|
||||
node "${STALL_HERMES}" "${METRO_PORT}" 3000 "${APP_ID}" &
|
||||
local stall_pid="$!"
|
||||
sleep 0.25
|
||||
adb shell input keyevent BACK
|
||||
wait_for_ime false
|
||||
sleep 0.5
|
||||
capture_screen "${prefix}-during-stall.png"
|
||||
wait "${stall_pid}"
|
||||
capture_screen "${prefix}-after-stall.png"
|
||||
node "${ASSERT}" same-region "${prefix}-stall-baseline.png" "${prefix}-during-stall.png" 1400 430
|
||||
node "${ASSERT}" same-region "${prefix}-stall-baseline.png" "${prefix}-after-stall.png" 1400 430
|
||||
echo "PASS host=${host}: growth, header/keyboard/safe-area bounds, close/reopen height, hold-delete baseline, content/header dismissal, JS-stall motion"
|
||||
run_host_overlays "${host}"
|
||||
run_host_scroll "${host}"
|
||||
}
|
||||
|
||||
adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agent/${agent_id}" "${APP_ID}" >/dev/null
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
run_host_scenario chat
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="workspace-header-menu-trigger"' --settle
|
||||
ad wait 'id="workspace-header-new-agent"' 10000
|
||||
ad press 'id="workspace-header-new-agent"' --settle
|
||||
ad wait 'text="New Agent"' 10000
|
||||
run_host_scenario workspace-draft
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="menu-button"' --settle
|
||||
ad press 'id="sidebar-global-new-workspace"' --settle
|
||||
ad wait 'id="workspace-create-submit"' 10000
|
||||
run_host_scenario new-workspace
|
||||
|
||||
echo "Composer keyboard invariants passed"
|
||||
|
||||
@@ -4,16 +4,96 @@ import sharp from "sharp";
|
||||
|
||||
const [, , command, ...args] = process.argv;
|
||||
|
||||
if (command === "xml-composer-contained") {
|
||||
// uiautomator reports bounds clipped to the visible parent and omits nodes
|
||||
// that are entirely off screen.
|
||||
function xmlBounds(snapshot, attribute) {
|
||||
const node = snapshot.match(new RegExp(`<node[^>]*${attribute}[^>]*>`));
|
||||
if (!node) throw new Error(`Missing node: ${attribute}`);
|
||||
const bounds = node[0].match(/bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/);
|
||||
if (!bounds) throw new Error(`Missing bounds: ${attribute}`);
|
||||
return {
|
||||
left: Number(bounds[1]),
|
||||
top: Number(bounds[2]),
|
||||
right: Number(bounds[3]),
|
||||
bottom: Number(bounds[4]),
|
||||
};
|
||||
}
|
||||
const xmlBoundsById = (snapshot, id) => xmlBounds(snapshot, `resource-id="${id}"`);
|
||||
const xmlEditTextBounds = (snapshot) => xmlBounds(snapshot, 'class="android.widget.EditText"');
|
||||
const boundsHeight = (bounds) => bounds.bottom - bounds.top;
|
||||
|
||||
if (command === "stream-moved") {
|
||||
const [beforePath, afterPath, topArgument = "330", heightArgument = "1000"] = args;
|
||||
const { width } = await sharp(beforePath).metadata();
|
||||
const region = {
|
||||
left: 16,
|
||||
top: Number(topArgument),
|
||||
width: width - 32,
|
||||
height: Number(heightArgument),
|
||||
};
|
||||
const [before, after] = await Promise.all(
|
||||
[beforePath, afterPath].map((path) =>
|
||||
sharp(path).extract(region).removeAlpha().raw().toBuffer(),
|
||||
),
|
||||
);
|
||||
let changed = 0;
|
||||
for (let offset = 0; offset < before.length; offset += 3) {
|
||||
if (
|
||||
[0, 1, 2].some((channel) => Math.abs(before[offset + channel] - after[offset + channel]) > 8)
|
||||
)
|
||||
changed++;
|
||||
}
|
||||
const fraction = changed / (before.length / 3);
|
||||
// Exclude the scrollbar rail and require more than incidental icon/antialias changes.
|
||||
if (fraction < 0.01)
|
||||
throw new Error(
|
||||
`Native swipe did not move stream content: ${(fraction * 100).toFixed(2)}% changed`,
|
||||
);
|
||||
process.stdout.write(
|
||||
`Native swipe moved stream content: ${(fraction * 100).toFixed(2)}% changed\n`,
|
||||
);
|
||||
} else if (command === "snapshot-xml") {
|
||||
const [inputPath, outputPath] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(inputPath, "utf8"));
|
||||
if (!snapshot.success || !snapshot.data.nodes.length) throw new Error("Empty device snapshot");
|
||||
const escape = (value) =>
|
||||
String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
const nodes = snapshot.data.nodes
|
||||
.filter((node) => node.rect && node.visibleToUser !== false)
|
||||
.map((node) => {
|
||||
const { x, y, width, height } = node.rect;
|
||||
return `<node class="${escape(node.type)}" resource-id="${escape(node.identifier)}" content-desc="${escape(node.label)}" bounds="[${Math.round(x)},${Math.round(y)}][${Math.round(x + width)},${Math.round(y + height)}]" />`;
|
||||
});
|
||||
await fs.writeFile(outputPath, `<hierarchy>${nodes.join("")}</hierarchy>`);
|
||||
} else if (command === "xml-background-point") {
|
||||
const [snapshotPath, surface] = args;
|
||||
const snapshot = await fs.readFile(snapshotPath, "utf8");
|
||||
const bounds = xmlBoundsById(
|
||||
snapshot,
|
||||
surface === "header" ? "composer-dock-header" : "composer-dock-content",
|
||||
);
|
||||
process.stdout.write(
|
||||
`${surface === "header" ? bounds.right - 4 : bounds.left + 4} ${bounds.bottom - 8}\n`,
|
||||
);
|
||||
} else if (command === "xml-content-follow-growth") {
|
||||
const [beforePath, afterPath] = args;
|
||||
const before = await fs.readFile(beforePath, "utf8");
|
||||
const after = await fs.readFile(afterPath, "utf8");
|
||||
const growth = boundsHeight(xmlEditTextBounds(after)) - boundsHeight(xmlEditTextBounds(before));
|
||||
const movement =
|
||||
xmlBoundsById(before, "composer-dock-content").bottom -
|
||||
xmlBoundsById(after, "composer-dock-content").bottom;
|
||||
if (growth <= 0 || Math.abs(movement - growth) > 1)
|
||||
throw new Error(`Content moved ${movement}px for ${growth}px input growth`);
|
||||
process.stdout.write(`Content followed input growth: ${growth}px\n`);
|
||||
} else if (command === "xml-composer-contained") {
|
||||
const [snapshotPath, imeTopArgument, densityArgument] = args;
|
||||
const snapshot = await fs.readFile(snapshotPath, "utf8");
|
||||
const boundsFor = (id) => {
|
||||
const node = snapshot.match(new RegExp(`<node[^>]*resource-id="${id}"[^>]*>`));
|
||||
if (!node) throw new Error(`Missing node: ${id}`);
|
||||
const bounds = node[0].match(/bounds="\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]"/);
|
||||
if (!bounds) throw new Error(`Missing bounds: ${id}`);
|
||||
return { top: Number(bounds[2]), bottom: Number(bounds[4]) };
|
||||
};
|
||||
const boundsFor = (id) => xmlBoundsById(snapshot, id);
|
||||
const viewport = boundsFor("composer-viewport");
|
||||
const content = boundsFor("composer-viewport-content");
|
||||
const composer = boundsFor("message-input-root");
|
||||
@@ -35,7 +115,7 @@ if (command === "xml-composer-contained") {
|
||||
`Composer contained: top=${composer.top}, header=${viewport.top}, bottom=${composer.bottom}, keyboard=${imeTopArgument}\n`,
|
||||
);
|
||||
} else if (command === "rect") {
|
||||
const [snapshotPath, identifier] = args;
|
||||
const [snapshotPath, identifier, edge] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const node = snapshot.data.nodes.find(
|
||||
(candidate) =>
|
||||
@@ -45,7 +125,8 @@ if (command === "xml-composer-contained") {
|
||||
);
|
||||
if (!node) throw new Error(`Missing node: ${identifier}`);
|
||||
const { x, y, width, height } = node.rect;
|
||||
process.stdout.write(`${Math.round(x + width / 2)} ${Math.round(y + height / 2)} ${height}\n`);
|
||||
const targetX = edge === "right" ? x + width - 4 : x + width / 2;
|
||||
process.stdout.write(`${Math.round(targetX)} ${Math.round(y + height / 2)} ${height}\n`);
|
||||
} else if (command === "above-y") {
|
||||
const [snapshotPath, upperIdentifier, lowerYArgument] = args;
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
@@ -91,9 +172,46 @@ if (command === "xml-composer-contained") {
|
||||
if (changedRatio > 0.01) {
|
||||
throw new Error(`Header changed while the keyboard opened (${changedRatio.toFixed(3)})`);
|
||||
}
|
||||
} else if (command === "xml-fields-follow-growth") {
|
||||
// Growing the draft with the keyboard open moves the setup fields up by the
|
||||
// same amount; the composer never eats their bottom rows.
|
||||
const [beforePath, afterPath] = args;
|
||||
const [before, after] = await Promise.all([
|
||||
fs.readFile(beforePath, "utf8"),
|
||||
fs.readFile(afterPath, "utf8"),
|
||||
]);
|
||||
const growth = boundsHeight(xmlEditTextBounds(after)) - boundsHeight(xmlEditTextBounds(before));
|
||||
if (growth <= 0) throw new Error(`Composer did not grow (${growth}px)`);
|
||||
const fieldsBefore = xmlBoundsById(before, "new-workspace-launch-trigger");
|
||||
const fieldsAfter = xmlBoundsById(after, "new-workspace-launch-trigger");
|
||||
const moved = fieldsBefore.bottom - fieldsAfter.bottom;
|
||||
if (Math.abs(moved - growth) > 2) {
|
||||
throw new Error(`Setup fields moved ${moved}px while the composer grew ${growth}px`);
|
||||
}
|
||||
process.stdout.write(`Setup fields followed composer growth: ${growth}px\n`);
|
||||
} else if (command === "xml-fields-above-composer") {
|
||||
// With the keyboard closed the setup fields sit fully visible above the composer.
|
||||
const [snapshotPath] = args;
|
||||
const snapshot = await fs.readFile(snapshotPath, "utf8");
|
||||
const fields = xmlBoundsById(snapshot, "new-workspace-launch-trigger");
|
||||
const content = xmlBoundsById(snapshot, "composer-viewport-content");
|
||||
if (boundsHeight(fields) < 40) {
|
||||
throw new Error(`Setup fields are clipped: launch row height=${boundsHeight(fields)}`);
|
||||
}
|
||||
if (fields.bottom > content.top) {
|
||||
throw new Error(
|
||||
`Setup fields overlap the composer: fields=${fields.bottom}, composer=${content.top}`,
|
||||
);
|
||||
}
|
||||
process.stdout.write(
|
||||
`Setup fields visible above the composer: ${fields.bottom} <= ${content.top}\n`,
|
||||
);
|
||||
} else if (command === "same-input-height") {
|
||||
const [firstPath, secondPath] = args;
|
||||
const readInputHeight = async (snapshotPath) => {
|
||||
if (snapshotPath.endsWith(".xml")) {
|
||||
return boundsHeight(xmlEditTextBounds(await fs.readFile(snapshotPath, "utf8")));
|
||||
}
|
||||
const snapshot = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
|
||||
const input = snapshot.data.nodes.find((candidate) => candidate.type.endsWith("EditText"));
|
||||
if (!input) throw new Error(`Missing composer input in ${snapshotPath}`);
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
beginDrag,
|
||||
|
||||
@@ -22,8 +22,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy";
|
||||
import { useKeyboardStreamInset } from "@/keyboard/shift";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
@@ -105,7 +104,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const isUserScrollActiveRef = useRef(false);
|
||||
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
||||
const settledKeyboardShift = useSettledKeyboardShift();
|
||||
const streamKeyboardInset = useKeyboardStreamInset();
|
||||
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const isNativeViewportSettlingRef = useRef(false);
|
||||
@@ -275,14 +274,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
const streamKeyboardInset = useMemo(
|
||||
() =>
|
||||
resolveStreamKeyboardInset({
|
||||
platform: Platform.OS === "ios" ? "ios" : "android",
|
||||
settledShift: settledKeyboardShift,
|
||||
}),
|
||||
[settledKeyboardShift],
|
||||
);
|
||||
const listContentContainerStyle = useMemo(
|
||||
() => [
|
||||
baseListContentContainerStyle,
|
||||
|
||||
@@ -88,7 +88,7 @@ import { useLatchedBoolean } from "@/hooks/use-latched-boolean";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { resolveExplorerSidebarPresentation } from "@/workspace-tabs/explorer-sidebar";
|
||||
import { KeyboardShiftProvider } from "@/hooks/use-keyboard-shift-style";
|
||||
import { KeyboardShiftProvider } from "@/keyboard/shift";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -17,17 +16,11 @@ interface ArchivedAgentCalloutProps {
|
||||
|
||||
export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCalloutProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
const handleUnarchive = useCallback(async () => {
|
||||
if (!client || !isConnected || isUnarchiving) return;
|
||||
setIsUnarchiving(true);
|
||||
@@ -41,7 +34,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
}, [client, isConnected, isUnarchiving, agentId]);
|
||||
|
||||
return (
|
||||
<KeyboardTranslateView style={containerStyle}>
|
||||
<KeyboardTranslateView style={styles.container}>
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.calloutStack}>
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
import { ChangesSurface } from "@/git/diff-pane";
|
||||
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
|
||||
import { useKeyboardShiftStyle } from "@/keyboard/shift";
|
||||
import { shouldUseCompactExplorerKeyboardPadding } from "@/keyboard/shift";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComposerDockBackground } from "@/composer/dock";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import type { LayoutChangeEvent } from "react-native";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
@@ -49,7 +50,7 @@ export function ScreenHeader({
|
||||
const rightCombinedStyle = useMemo(() => [styles.right, rightStyle], [rightStyle]);
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<ComposerDockBackground style={styles.header} testID="composer-dock-header">
|
||||
<View style={innerStyle}>
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
@@ -62,7 +63,7 @@ export function ScreenHeader({
|
||||
<View style={rightCombinedStyle}>{right}</View>
|
||||
</WindowChromeSafeArea>
|
||||
</View>
|
||||
</View>
|
||||
</ComposerDockBackground>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./keyboard-dock.native";
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
|
||||
interface KeyboardDockProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardDock({ children, style, ...props }: KeyboardDockProps) {
|
||||
return (
|
||||
<KeyboardTranslateView style={style} {...props}>
|
||||
{children}
|
||||
</KeyboardTranslateView>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface KeyboardDockProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardDock(props: KeyboardDockProps) {
|
||||
return <View {...props} />;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@getpaseo/protocol/terminal-input-mode";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardShiftStyle } from "@/keyboard/shift";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
measureFloatingPanelPortalHost,
|
||||
useFloatingPanelPortalHostName,
|
||||
} from "@/components/ui/floating-panel-portal";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import type { CreateWorkspaceRequestOptions } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
@@ -445,25 +446,27 @@ export function WorkspaceSetupDialog() {
|
||||
desktopMaxWidth={640}
|
||||
>
|
||||
<FileDropZone style={styles.section}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleCreateChatAgent}
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
textSource={chatDraft.textSource}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
<KeyboardTranslateView>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
|
||||
serverId={serverId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleCreateChatAgent}
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
textSource={chatDraft.textSource}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
cwd={sourceDirectory}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
inputWrapperStyle={styles.composerInputWrapper}
|
||||
/>
|
||||
</KeyboardTranslateView>
|
||||
</FileDropZone>
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { ComposerDockBackground } from "./internal/background";
|
||||
export { ComposerDockBackground } from "./internal/background";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps, StyleSheet } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
useDerivedValue,
|
||||
useAnimatedReaction,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { updateComposerCapacity, type ComposerCapacity } from "./internal/capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
/** Measure the stationary space below the header, outside keyboard translation. */
|
||||
function ComposerViewport({
|
||||
children,
|
||||
onLayout,
|
||||
bottomInset = 0,
|
||||
centered = false,
|
||||
...props
|
||||
}: ComposerViewportProps) {
|
||||
const measuredHeight = useSharedValue(0);
|
||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: measuredHeight.value,
|
||||
bottomInset,
|
||||
keyboardShift: layoutShift.value,
|
||||
centered,
|
||||
}),
|
||||
(geometry) => {
|
||||
if (geometry.height <= 0) return;
|
||||
sizing.value = updateComposerCapacity(sizing.value, geometry);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const capacity = useDerivedValue(() => sizing.value?.capacity);
|
||||
const measureViewport = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
measuredHeight.value = event.nativeEvent.layout.height;
|
||||
onLayout?.(event);
|
||||
},
|
||||
[measuredHeight, onLayout],
|
||||
);
|
||||
|
||||
return (
|
||||
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
|
||||
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bound the complete composer (including its controls), not just the text input. */
|
||||
function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
const capacity = useContext(ViewportCapacity);
|
||||
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||
const constraint = useAnimatedStyle(() => ({
|
||||
// Only viewport changes and keyboard start/end events trigger layout. Motion
|
||||
// stays in KeyboardTranslateView and never changes this constraint per frame.
|
||||
maxHeight: capacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View testID="composer-viewport-content" {...props} style={[style, constraint]} />
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
/** The stationary viewport, translated surface, and bounded composer are one owner. */
|
||||
export function ComposerDock({
|
||||
children: [content, composer, overlay],
|
||||
centered = false,
|
||||
}: ComposerDockProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
// Preserve the existing centered form's visual balance on tablets.
|
||||
const bottomInset = centered ? HEADER_INNER_HEIGHT + 24 : 0;
|
||||
if (centered) {
|
||||
return (
|
||||
<ComposerViewport
|
||||
style={[dockStyles.centeredViewport, { paddingBottom: bottomInset }]}
|
||||
bottomInset={bottomInset}
|
||||
centered
|
||||
>
|
||||
<KeyboardTranslateView style={dockStyles.centered}>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<ScrollView style={dockStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
</ScrollView>
|
||||
<ComposerViewportContent style={dockStyles.centeredComposer}>
|
||||
{composer}
|
||||
</ComposerViewportContent>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComposerViewport style={dockStyles.viewport}>
|
||||
<KeyboardTranslateView style={dockStyles.surface}>
|
||||
<View
|
||||
testID="composer-dock-content"
|
||||
collapsable={false}
|
||||
style={dockStyles.content}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{/* A responder ancestor intercepts native scroll drags on Android Fabric.
|
||||
Only unclaimed background touches may reach this sibling. */}
|
||||
<ComposerDockBackground style={StyleSheet.absoluteFill} />
|
||||
<View style={dockStyles.content} pointerEvents="box-none">
|
||||
{content}
|
||||
</View>
|
||||
</View>
|
||||
<ComposerViewportContent style={dockStyles.composer}>
|
||||
<View style={[dockStyles.composer, { paddingBottom: insets.bottom }]}>{composer}</View>
|
||||
</ComposerViewportContent>
|
||||
{overlay}
|
||||
</KeyboardTranslateView>
|
||||
</ComposerViewport>
|
||||
);
|
||||
}
|
||||
|
||||
const dockStyles = StyleSheet.create({
|
||||
viewport: { flex: 1, overflow: "hidden" },
|
||||
surface: { flex: 1 },
|
||||
content: { flex: 1, justifyContent: "flex-end" },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
centeredViewport: { flex: 1, alignItems: "center", justifyContent: "center" },
|
||||
centered: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
|
||||
// Reserve the composer's own capped height before the setup scroll view shrinks.
|
||||
centeredComposer: { width: "100%", flexShrink: 0 },
|
||||
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
export { ComposerDockBackground } from "./internal/background";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import type { ReactNode } from "react";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
|
||||
interface ComposerDockProps {
|
||||
children: [ReactNode, ReactNode, ReactNode?];
|
||||
centered?: boolean;
|
||||
}
|
||||
|
||||
export function ComposerDock({
|
||||
children: [content, composer, overlay],
|
||||
centered = false,
|
||||
}: ComposerDockProps) {
|
||||
if (centered) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<View style={styles.form}>
|
||||
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
|
||||
{content}
|
||||
</ScrollView>
|
||||
<View style={styles.centeredComposer}>{composer}</View>
|
||||
{overlay}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.surface}>
|
||||
<View style={styles.content}>{content}</View>
|
||||
<View style={styles.composer}>{composer}</View>
|
||||
{overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
surface: { flex: 1, overflow: "hidden" },
|
||||
content: { flex: 1, justifyContent: "flex-end" },
|
||||
composer: { width: "100%", flexShrink: 1 },
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingBottom: HEADER_INNER_HEIGHT + 24,
|
||||
},
|
||||
form: { flexShrink: 1, width: "100%", maxWidth: MAX_CONTENT_WIDTH },
|
||||
centeredComposer: { flexShrink: 0 },
|
||||
setup: { flexGrow: 0, flexShrink: 1, minHeight: 0 },
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Keyboard, Pressable, type ViewProps } from "react-native";
|
||||
|
||||
/** Use behind scrollable content as a sibling, never as its responder ancestor. */
|
||||
export function ComposerDockBackground(props: ViewProps) {
|
||||
return <Pressable {...props} accessible={false} onPress={Keyboard.dismiss} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
export function ComposerDockBackground(props: ViewProps) {
|
||||
return <View {...props} />;
|
||||
}
|
||||
+22
-32
@@ -2,47 +2,37 @@ import { describe, expect, it } from "vitest";
|
||||
import { resolveComposerCapacity, updateComposerCapacity } from "./capacity";
|
||||
|
||||
describe("composer viewport", () => {
|
||||
it("preserves the editing capacity when the keyboard closes on a docked composer", () => {
|
||||
it("preserves the editing capacity when the keyboard closes", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }, "retain");
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }, "retain");
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 });
|
||||
expect(open.capacity).toBe(245);
|
||||
expect(closed.capacity).toBe(open.capacity);
|
||||
expect(
|
||||
updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }, "retain").capacity,
|
||||
).toBe(303);
|
||||
});
|
||||
|
||||
it("releases the whole viewport when the keyboard closes on a form", () => {
|
||||
const viewport = { height: 582, bottomInset: 24, centered: false };
|
||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 }, "release");
|
||||
const closed = updateComposerCapacity(open, { ...viewport, keyboardShift: 0 }, "release");
|
||||
expect(open.capacity).toBe(245);
|
||||
expect(closed.capacity).toBe(553);
|
||||
expect(
|
||||
updateComposerCapacity(closed, { ...viewport, keyboardShift: 308 }, "release").capacity,
|
||||
).toBe(245);
|
||||
expect(updateComposerCapacity(closed, { ...viewport, keyboardShift: 250 }).capacity).toBe(303);
|
||||
});
|
||||
|
||||
it("remeasures the viewport without forgetting the keyboard reservation", () => {
|
||||
const open = updateComposerCapacity(
|
||||
undefined,
|
||||
{ height: 582, bottomInset: 24, centered: false, keyboardShift: 308 },
|
||||
"retain",
|
||||
);
|
||||
const open = updateComposerCapacity(undefined, {
|
||||
height: 582,
|
||||
bottomInset: 24,
|
||||
centered: false,
|
||||
keyboardShift: 308,
|
||||
});
|
||||
expect(
|
||||
updateComposerCapacity(
|
||||
open,
|
||||
{ height: 650, bottomInset: 24, centered: false, keyboardShift: 0 },
|
||||
"retain",
|
||||
).capacity,
|
||||
updateComposerCapacity(open, {
|
||||
height: 650,
|
||||
bottomInset: 24,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}).capacity,
|
||||
).toBe(313);
|
||||
expect(
|
||||
updateComposerCapacity(
|
||||
open,
|
||||
{ height: 0, bottomInset: 24, centered: false, keyboardShift: 0 },
|
||||
"retain",
|
||||
),
|
||||
updateComposerCapacity(open, {
|
||||
height: 0,
|
||||
bottomInset: 24,
|
||||
centered: false,
|
||||
keyboardShift: 0,
|
||||
}),
|
||||
).toEqual(open);
|
||||
});
|
||||
it("leaves five points below the header for a bottom-anchored composer", () => {
|
||||
+3
-13
@@ -10,14 +10,6 @@ export interface ComposerCapacity {
|
||||
capacity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the closed keyboard leaves behind. A docked chat composer retains its last
|
||||
* editing reservation so a capped draft does not expand over the stream. A form
|
||||
* that scrolls inside the constrained space releases it so the whole form is
|
||||
* visible again once the keyboard is gone.
|
||||
*/
|
||||
export type KeyboardReservePolicy = "retain" | "release";
|
||||
|
||||
export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
"worklet";
|
||||
// A centered form grows upward by half its height. Reserve both halves so
|
||||
@@ -30,15 +22,13 @@ export function resolveComposerCapacity(input: ComposerGeometry): number {
|
||||
export function updateComposerCapacity(
|
||||
previous: ComposerCapacity | undefined,
|
||||
input: ComposerGeometry,
|
||||
policy: KeyboardReservePolicy,
|
||||
): ComposerCapacity {
|
||||
"worklet";
|
||||
if (input.height <= 0 && previous) return previous;
|
||||
// A subsequent keyboard opening always supplies the next reservation.
|
||||
// Closing the keyboard moves the composer; it does not enlarge its editing
|
||||
// capacity. A subsequent keyboard opening supplies the next reservation.
|
||||
const keyboardReserve =
|
||||
input.keyboardShift > 0 || policy === "release"
|
||||
? input.keyboardShift
|
||||
: (previous?.keyboardReserve ?? 0);
|
||||
input.keyboardShift > 0 ? input.keyboardShift : (previous?.keyboardReserve ?? 0);
|
||||
return {
|
||||
keyboardReserve,
|
||||
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
|
||||
@@ -2,9 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
@@ -329,7 +327,6 @@ export function WorkspaceDraftAgentTab({
|
||||
onOpenImportSheet,
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
|
||||
workspaceDirectory: w.workspaceDirectory,
|
||||
@@ -612,11 +609,6 @@ export function WorkspaceDraftAgentTab({
|
||||
focusInputRef.current = focus;
|
||||
}, []);
|
||||
|
||||
const inputAreaWrapperStyle = useMemo(
|
||||
() => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
const handleDropdownCloseFocus = useCallback(() => {
|
||||
focusInputRef.current?.();
|
||||
}, []);
|
||||
@@ -629,77 +621,74 @@ export function WorkspaceDraftAgentTab({
|
||||
}),
|
||||
[composerState.agentControls, handleDropdownCloseFocus, isSubmitting],
|
||||
);
|
||||
return (
|
||||
<ComposerViewport style={styles.container} bottomInset={insets.bottom} keyboardReserve="retain">
|
||||
<FileDropZone style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.configScrollContent}
|
||||
>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
const dockContent = (
|
||||
<View style={styles.contentContainer}>
|
||||
{isSubmitting && draftAgent ? (
|
||||
<View style={styles.streamContainer}>
|
||||
<AgentStreamView
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
turnPresentation={turnPresentation}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
<ComposerViewportContent style={animatedStaticStyles.inputAreaWrapper}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||
<View style={styles.configSection}>
|
||||
{formErrorMessage ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ComposerViewportContent>
|
||||
</KeyboardTranslateView>
|
||||
</FileDropZone>
|
||||
</ComposerViewport>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ComposerDock>
|
||||
{dockContent}
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
<ComposerImportPill onPress={importPillPress} />
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
textSource={draftInput.textSource}
|
||||
onChangeText={draftInput.editText}
|
||||
textReplacement={draftInput.textReplacement}
|
||||
attachments={draftInput.attachments}
|
||||
attachmentScopeKeys={attachmentScopeKeys}
|
||||
onOpenWorkspaceAttachment={handleOpenWorkspaceAttachment}
|
||||
onChangeAttachments={draftInput.setAttachments}
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</View>
|
||||
</ComposerDock>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
type DraftAgentControlsProps,
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
@@ -989,8 +988,6 @@ interface ComposerProps {
|
||||
agentControls?: DraftAgentControlsProps;
|
||||
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
|
||||
inputWrapperStyle?: import("react-native").ViewStyle;
|
||||
/** When true, a parent wrapper owns the keyboard shift, so the composer skips its own. */
|
||||
externalKeyboardShift?: boolean;
|
||||
/** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */
|
||||
isCompactLayout?: boolean;
|
||||
/**
|
||||
@@ -1278,7 +1275,6 @@ function ComposerContentImpl({
|
||||
onAttentionPromptSend,
|
||||
agentControls,
|
||||
inputWrapperStyle,
|
||||
externalKeyboardShift,
|
||||
isCompactLayout: isCompactLayoutOverride,
|
||||
inputMode = "chat",
|
||||
readOnly = false,
|
||||
@@ -2415,10 +2411,7 @@ function ComposerContentImpl({
|
||||
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
|
||||
isMessageInputFocused={isMessageInputFocused}
|
||||
/>
|
||||
<KeyboardTranslateView
|
||||
style={animatedStaticStyles.container}
|
||||
enabled={!externalKeyboardShift}
|
||||
>
|
||||
<View style={animatedStaticStyles.container}>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
@@ -2516,7 +2509,7 @@ function ComposerContentImpl({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardTranslateView>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { createContext, useCallback, useContext } from "react";
|
||||
import { View, type LayoutChangeEvent, type ViewProps } from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
useDerivedValue,
|
||||
useAnimatedReaction,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import {
|
||||
updateComposerCapacity,
|
||||
type ComposerCapacity,
|
||||
type KeyboardReservePolicy,
|
||||
} from "./capacity";
|
||||
|
||||
const ViewportCapacity = createContext<SharedValue<number | undefined> | null>(null);
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
centered?: boolean;
|
||||
keyboardReserve?: KeyboardReservePolicy;
|
||||
}
|
||||
|
||||
/** Measure the stationary space below the header, outside keyboard translation. */
|
||||
export function ComposerViewport({
|
||||
children,
|
||||
onLayout,
|
||||
bottomInset = 0,
|
||||
centered = false,
|
||||
keyboardReserve = "release",
|
||||
...props
|
||||
}: ComposerViewportProps) {
|
||||
const measuredHeight = useSharedValue(0);
|
||||
const sizing = useSharedValue<ComposerCapacity | undefined>(undefined);
|
||||
const { layoutShift } = useKeyboardShift();
|
||||
useAnimatedReaction(
|
||||
() => ({
|
||||
height: measuredHeight.value,
|
||||
bottomInset,
|
||||
keyboardShift: layoutShift.value,
|
||||
centered,
|
||||
}),
|
||||
(geometry) => {
|
||||
if (geometry.height <= 0) return;
|
||||
sizing.value = updateComposerCapacity(sizing.value, geometry, keyboardReserve);
|
||||
},
|
||||
[keyboardReserve],
|
||||
);
|
||||
const capacity = useDerivedValue(() => sizing.value?.capacity);
|
||||
const measureViewport = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
measuredHeight.value = event.nativeEvent.layout.height;
|
||||
onLayout?.(event);
|
||||
},
|
||||
[measuredHeight, onLayout],
|
||||
);
|
||||
|
||||
return (
|
||||
<View testID="composer-viewport" {...props} onLayout={measureViewport} collapsable={false}>
|
||||
<ViewportCapacity.Provider value={capacity}>{children}</ViewportCapacity.Provider>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bound the complete composer (including its controls), not just the text input. */
|
||||
export function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||
const capacity = useContext(ViewportCapacity);
|
||||
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||
const constraint = useAnimatedStyle(() => ({
|
||||
// Only viewport changes and keyboard start/end events trigger layout. Motion
|
||||
// stays in KeyboardTranslateView and never changes this constraint per frame.
|
||||
maxHeight: capacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View testID="composer-viewport-content" {...props} style={[style, constraint]} />
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface ComposerViewportProps extends ViewProps {
|
||||
bottomInset?: number;
|
||||
centered?: boolean;
|
||||
keyboardReserve?: "retain" | "release";
|
||||
}
|
||||
|
||||
export function ComposerViewport({
|
||||
bottomInset: _bottomInset,
|
||||
centered: _centered,
|
||||
keyboardReserve: _keyboardReserve,
|
||||
...props
|
||||
}: ComposerViewportProps) {
|
||||
return <View {...props} />;
|
||||
}
|
||||
|
||||
export function ComposerViewportContent(props: ViewProps) {
|
||||
return <View {...props} />;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { InlineReviewThread } from "@/review";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useKeyboardShift } from "@/keyboard/shift";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { DocumentFileHeader } from "./document-file-header";
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider";
|
||||
export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context";
|
||||
export { KeyboardTranslateView } from "./internal/translate";
|
||||
export { shouldUseCompactExplorerKeyboardPadding } from "./internal/policy";
|
||||
export { useKeyboardStreamInset } from "./internal/stream";
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
resolveKeyboardShift,
|
||||
reserveKeyboardLayoutShift,
|
||||
shouldUseCompactExplorerKeyboardPadding,
|
||||
} from "./keyboard-shift-policy";
|
||||
} from "./policy";
|
||||
|
||||
describe("keyboard layout reservation", () => {
|
||||
it("reserves the opening destination before motion and releases it only after closing", () => {
|
||||
+2
-6
@@ -19,12 +19,8 @@ import {
|
||||
resolveKeyboardShift,
|
||||
reserveKeyboardLayoutShift,
|
||||
shouldReconcileHiddenKeyboardEnd,
|
||||
} from "@/hooks/keyboard-shift-policy";
|
||||
import {
|
||||
KeyboardShiftContext,
|
||||
SettledKeyboardShiftContext,
|
||||
useKeyboardShift,
|
||||
} from "@/hooks/keyboard-shift-context";
|
||||
} from "./policy";
|
||||
import { KeyboardShiftContext, SettledKeyboardShiftContext, useKeyboardShift } from "./context";
|
||||
|
||||
type KeyboardShiftMode = "translate" | "padding";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useSettledKeyboardShift } from "./context";
|
||||
import { resolveStreamKeyboardInset } from "./policy";
|
||||
|
||||
/** Preserve the translated inverted list's far-end scroll range only after motion settles. */
|
||||
export function useKeyboardStreamInset() {
|
||||
const settledShift = useSettledKeyboardShift();
|
||||
return useMemo(
|
||||
() =>
|
||||
resolveStreamKeyboardInset({
|
||||
platform: Platform.OS === "ios" ? "ios" : "android",
|
||||
settledShift,
|
||||
}),
|
||||
[settledShift],
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { useKeyboardShift } from "./context";
|
||||
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
@@ -16,15 +16,13 @@ import React, {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { KeyboardDock } from "@/components/keyboard-dock";
|
||||
import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { RetainedChatContent } from "./retained-chat-content";
|
||||
@@ -1272,7 +1270,36 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
const contentContainer = <View style={styles.contentContainer}>{streamContent}</View>;
|
||||
|
||||
const dockContent = (
|
||||
<View style={styles.contentContainer}>
|
||||
{streamContent}
|
||||
|
||||
{showHistorySyncError ? (
|
||||
<TimelineSyncErrorCallout isRetrying={isRetryingHistorySync} onRetry={retryTimelineSync} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
|
||||
const dockOverlay = (
|
||||
<>
|
||||
{showHistorySyncOverlay ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TimelineSyncStatus sync={timelineSync} toast={toast} onDismiss={dismiss} />
|
||||
</>
|
||||
);
|
||||
|
||||
const dock = (
|
||||
<ChatSurface disabled={isArchivingCurrentAgent}>
|
||||
{dockContent}
|
||||
{composerSection}
|
||||
{dockOverlay}
|
||||
</ChatSurface>
|
||||
);
|
||||
|
||||
return (
|
||||
<RewindComposerRestoreProvider
|
||||
@@ -1280,45 +1307,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
setText={agentInputDraft.replaceText}
|
||||
onRewindComplete={handleRewindComplete}
|
||||
>
|
||||
<ComposerViewport style={styles.root} keyboardReserve="retain">
|
||||
<DockedChatSurface disabled={isArchivingCurrentAgent}>
|
||||
{contentContainer}
|
||||
|
||||
{showHistorySyncError ? (
|
||||
<View style={styles.timelineSyncCalloutRail}>
|
||||
<View style={styles.timelineSyncCalloutContent}>
|
||||
<View style={styles.timelineSyncCallout} testID="agent-timeline-sync-error">
|
||||
<Text style={styles.timelineSyncCalloutText}>
|
||||
{t("agentPanel.states.timelineSyncFailed")}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={retryTimelineSync}
|
||||
disabled={isRetryingHistorySync}
|
||||
testID="agent-timeline-sync-retry"
|
||||
>
|
||||
{isRetryingHistorySync
|
||||
? t("agentPanel.states.timelineSyncRetrying")
|
||||
: t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ComposerViewportContent style={animatedStaticStyles.inputAreaWrapper}>
|
||||
{composerSection}
|
||||
</ComposerViewportContent>
|
||||
|
||||
{showHistorySyncOverlay ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<TimelineSyncStatus sync={timelineSync} toast={toast} onDismiss={dismiss} />
|
||||
</DockedChatSurface>
|
||||
<View style={styles.root}>
|
||||
{dock}
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
@@ -1327,18 +1317,52 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
<Text style={styles.archivingSubtitle}>{t("agentPanel.states.archivingSubtitle")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</ComposerViewport>
|
||||
</View>
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
});
|
||||
|
||||
function DockedChatSurface({ children, disabled }: { children: ReactNode; disabled: boolean }) {
|
||||
function ChatSurface({
|
||||
children,
|
||||
disabled,
|
||||
}: {
|
||||
children: [ReactNode, ReactNode, ReactNode];
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<KeyboardDock style={styles.container}>
|
||||
<FileDropZone style={styles.container} disabled={disabled}>
|
||||
{children}
|
||||
</FileDropZone>
|
||||
</KeyboardDock>
|
||||
<FileDropZone style={styles.container} disabled={disabled}>
|
||||
<ComposerDock>{children}</ComposerDock>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineSyncErrorCallout({
|
||||
isRetrying,
|
||||
onRetry,
|
||||
}: {
|
||||
isRetrying: boolean;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.timelineSyncCalloutRail}>
|
||||
<View style={styles.timelineSyncCalloutContent}>
|
||||
<View style={styles.timelineSyncCallout} testID="agent-timeline-sync-error">
|
||||
<Text style={styles.timelineSyncCalloutText}>
|
||||
{t("agentPanel.states.timelineSyncFailed")}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={onRetry}
|
||||
disabled={isRetrying}
|
||||
testID="agent-timeline-sync-retry"
|
||||
>
|
||||
{isRetrying ? t("agentPanel.states.timelineSyncRetrying") : t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1518,7 +1542,6 @@ function ActiveAgentComposer({
|
||||
onComposerHeightChange: (height: number) => void;
|
||||
onMessageSent: () => void;
|
||||
}) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const { onLayout: onInputAreaLayout, isBelow: isCompactComposerLayout } = useContainerWidthBelow(
|
||||
COMPACT_FORM_FACTOR_WIDTH,
|
||||
@@ -1593,18 +1616,12 @@ function ActiveAgentComposer({
|
||||
],
|
||||
);
|
||||
|
||||
const inputAreaStyle = useMemo(
|
||||
() => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={inputAreaStyle} onLayout={onInputAreaLayout}>
|
||||
<View style={animatedStaticStyles.inputAreaWrapper} onLayout={onInputAreaLayout}>
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
blurOnSubmit={isNative}
|
||||
isPaneFocused={isPaneFocused}
|
||||
textSource={agentInputDraft.textSource}
|
||||
@@ -1715,8 +1732,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
// KeyboardDock translates the chat surface while the keyboard moves; clip it at the header edge.
|
||||
overflow: "hidden",
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
|
||||
@@ -6,20 +6,17 @@ import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { ReactElement, RefObject } from "react";
|
||||
import type { ReactElement, ReactNode, RefObject } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Pressable, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import type { PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { Composer } from "@/composer";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { ComposerViewport, ComposerViewportContent } from "@/composer/viewport";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { ComposerDock } from "@/composer/dock";
|
||||
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
||||
import {
|
||||
resolveComposerAttachmentSubmitFormat,
|
||||
@@ -36,7 +33,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { HEADER_INNER_HEIGHT, MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
|
||||
@@ -689,8 +686,13 @@ function IsolationPickerTrigger({
|
||||
|
||||
// Wraps a single argument control in the mobile vertical stack. On desktop the
|
||||
// controls are laid out in one horizontal row, so no per-control wrapper is used.
|
||||
// Decorative padding must not block the dock background from iOS hit testing.
|
||||
function FormRow({ children }: { children: React.ReactNode }) {
|
||||
return <View style={styles.row}>{children}</View>;
|
||||
return (
|
||||
<View style={styles.row} pointerEvents="box-none">
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceIsolationState {
|
||||
@@ -740,13 +742,6 @@ function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string {
|
||||
: t("newWorkspace.isolation.local");
|
||||
}
|
||||
|
||||
function getContentStyle(input: { isCompact: boolean; insetBottom: number }) {
|
||||
if (input.isCompact) {
|
||||
return [styles.content, styles.contentCompact, { paddingBottom: input.insetBottom }];
|
||||
}
|
||||
return [styles.content, styles.contentCentered, { paddingBottom: input.insetBottom }];
|
||||
}
|
||||
|
||||
function normalizeBranchDetails(
|
||||
data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined,
|
||||
): BranchPickerDetail[] {
|
||||
@@ -1602,23 +1597,27 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
);
|
||||
|
||||
return isCompact ? (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.formStack} pointerEvents="box-none">
|
||||
<FormRow>{projectControl}</FormRow>
|
||||
{hostControl ? <FormRow>{hostControl}</FormRow> : null}
|
||||
{isolationControl ? <FormRow>{isolationControl}</FormRow> : null}
|
||||
{baseControl ? <FormRow>{baseControl}</FormRow> : null}
|
||||
<FormRow>{launchControl}</FormRow>
|
||||
{/* Keep fixed stack height without separating the visible controls. */}
|
||||
{isolationControl ? null : <View style={styles.baseSpacer} />}
|
||||
{baseControl ? null : <View style={styles.baseSpacer} />}
|
||||
{isolationControl ? null : <View style={styles.baseSpacer} pointerEvents="none" />}
|
||||
{baseControl ? null : <View style={styles.baseSpacer} pointerEvents="none" />}
|
||||
</View>
|
||||
) : (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.formStackDesktop}>
|
||||
<View
|
||||
testID="new-workspace-ref-picker-row"
|
||||
style={styles.formStackDesktop}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{projectControl}
|
||||
{hostControl}
|
||||
{isolationControl}
|
||||
{baseControl}
|
||||
<View style={styles.launchSpacer} />
|
||||
<View style={styles.launchSpacer} pointerEvents="none" />
|
||||
{launchControl}
|
||||
</View>
|
||||
);
|
||||
@@ -1634,7 +1633,6 @@ export function NewWorkspaceScreen({
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const toast = useToast();
|
||||
const mergeWorkspaces = useCallback(
|
||||
@@ -2290,15 +2288,6 @@ export function NewWorkspaceScreen({
|
||||
],
|
||||
);
|
||||
|
||||
const contentBottomInset = useMemo(
|
||||
() => (isCompact ? insets.bottom : HEADER_INNER_HEIGHT + theme.spacing[6]),
|
||||
[isCompact, insets.bottom, theme.spacing],
|
||||
);
|
||||
const contentStyle = useMemo(
|
||||
() => getContentStyle({ isCompact, insetBottom: contentBottomInset }),
|
||||
[isCompact, contentBottomInset],
|
||||
);
|
||||
|
||||
const agentControlsWithDisabled = useMemo(
|
||||
() =>
|
||||
composerState
|
||||
@@ -2379,101 +2368,108 @@ export function NewWorkspaceScreen({
|
||||
|
||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||
|
||||
const composer = isTerminalLaunch ? (
|
||||
<Composer
|
||||
key="terminal"
|
||||
inputMode="terminal"
|
||||
readOnly={!terminalTakesPrompt}
|
||||
placeholder={terminalPlaceholder}
|
||||
submitLabel={terminalSubmitLabel}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitTerminalLaunch}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
|
||||
submitButtonTestID="new-workspace-launch-submit"
|
||||
isSubmitLoading={isPending}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
textSource={terminalTextSource}
|
||||
onChangeText={setTerminalPromptText}
|
||||
textReplacement={terminalTextReplacement}
|
||||
attachments={NO_TERMINAL_ATTACHMENTS}
|
||||
onChangeAttachments={noopChangeAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={noopClearDraft}
|
||||
autoFocus={terminalTakesPrompt}
|
||||
autoFocusKey={launchFocusKey}
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
key="chat"
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={isPending}
|
||||
waitForForgeAutoAttachOnSubmit
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
textSource={chatDraft.textSource}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
onForgeChangeRequestDetected={handleForgeChangeRequestDetected}
|
||||
onForgeChangeRequestAutoAttach={handleForgeChangeRequestAutoAttach}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
autoFocusKey={launchFocusKey}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ScreenHeader left={screenHeaderLeft} borderless />
|
||||
<ComposerViewport style={contentStyle} bottomInset={contentBottomInset} centered={!isCompact}>
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<KeyboardTranslateView style={animatedStaticStyles.centered}>
|
||||
<ComposerViewportContent style={animatedStaticStyles.form}>
|
||||
<ScrollView style={animatedStaticStyles.setup} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.composerTitleContainer}>
|
||||
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
|
||||
</View>
|
||||
{formStack}
|
||||
</ScrollView>
|
||||
{isTerminalLaunch ? (
|
||||
<Composer
|
||||
key="terminal"
|
||||
externalKeyboardShift
|
||||
inputMode="terminal"
|
||||
readOnly={!terminalTakesPrompt}
|
||||
placeholder={terminalPlaceholder}
|
||||
submitLabel={terminalSubmitLabel}
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitTerminalLaunch}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
|
||||
submitButtonTestID="new-workspace-launch-submit"
|
||||
isSubmitLoading={isPending}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
textSource={terminalTextSource}
|
||||
onChangeText={setTerminalPromptText}
|
||||
textReplacement={terminalTextReplacement}
|
||||
attachments={NO_TERMINAL_ATTACHMENTS}
|
||||
onChangeAttachments={noopChangeAttachments}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={noopClearDraft}
|
||||
autoFocus={terminalTakesPrompt}
|
||||
autoFocusKey={launchFocusKey}
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
key="chat"
|
||||
externalKeyboardShift
|
||||
agentId={draftKey}
|
||||
serverId={selectedServerId}
|
||||
isPaneFocused={true}
|
||||
onSubmitMessage={handleSubmitNewWorkspace}
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={isPending}
|
||||
waitForForgeAutoAttachOnSubmit
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
textSource={chatDraft.textSource}
|
||||
onChangeText={chatDraft.editText}
|
||||
textReplacement={chatDraft.textReplacement}
|
||||
attachments={chatDraft.attachments}
|
||||
attachmentScopeKeys={visibleDraftContextScopeKeys}
|
||||
onChangeAttachments={chatDraft.setAttachments}
|
||||
onForgeChangeRequestDetected={handleForgeChangeRequestDetected}
|
||||
onForgeChangeRequestAutoAttach={handleForgeChangeRequestAutoAttach}
|
||||
cwd={selectedSourceDirectory ?? ""}
|
||||
clearDraft={handleClearDraft}
|
||||
autoFocus
|
||||
autoFocusKey={launchFocusKey}
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
agentControls={agentControlsWithDisabled}
|
||||
/>
|
||||
)}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</ComposerViewportContent>
|
||||
</KeyboardTranslateView>
|
||||
</ComposerViewport>
|
||||
<NewWorkspaceLayout
|
||||
isCompact={isCompact}
|
||||
title={t("newWorkspace.title")}
|
||||
formStack={formStack}
|
||||
>
|
||||
{composer}
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</NewWorkspaceLayout>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
centered: {
|
||||
flexShrink: 1,
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
form: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
setup: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
function NewWorkspaceLayout({
|
||||
isCompact,
|
||||
title,
|
||||
formStack,
|
||||
children,
|
||||
}: {
|
||||
isCompact: boolean;
|
||||
title: string;
|
||||
formStack: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const setupFields = (
|
||||
<>
|
||||
<View style={styles.composerTitleContainer} pointerEvents="none">
|
||||
<Text style={styles.composerTitle}>{title}</Text>
|
||||
</View>
|
||||
{formStack}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ComposerDock centered={!isCompact}>
|
||||
{setupFields}
|
||||
{children}
|
||||
</ComposerDock>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
@@ -2484,13 +2480,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
content: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
},
|
||||
contentCentered: {
|
||||
justifyContent: "center",
|
||||
},
|
||||
contentCompact: {
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
composerTitleContainer: {
|
||||
marginBottom: theme.spacing[8],
|
||||
|
||||
@@ -61,6 +61,7 @@ export default defineConfig({
|
||||
"@tanstack/react-query",
|
||||
"react-native-web",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-keyboard-controller",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -76,7 +77,11 @@ export default defineConfig({
|
||||
"react-native-gesture-handler > hoist-non-react-statics",
|
||||
"react-native-gesture-handler > invariant",
|
||||
],
|
||||
exclude: ["react-native-reanimated", "react-native-gesture-handler"],
|
||||
exclude: [
|
||||
"react-native-reanimated",
|
||||
"react-native-gesture-handler",
|
||||
"react-native-keyboard-controller",
|
||||
],
|
||||
},
|
||||
// The globals a React Native bundler defines, which esbuild is no longer there to supply for
|
||||
// the package excluded above.
|
||||
@@ -111,6 +116,14 @@ export default defineConfig({
|
||||
replacement: path.resolve(__dirname, "../relay/src/index.ts"),
|
||||
},
|
||||
{ find: "@", replacement: path.resolve(__dirname, "src") },
|
||||
// Keep keyboard-controller's imports in Vite so native aliases and platform extensions apply.
|
||||
{
|
||||
find: /^react-native-keyboard-controller$/,
|
||||
replacement: path.resolve(
|
||||
resolvePackageEntry("react-native-keyboard-controller"),
|
||||
"lib/module/index.js",
|
||||
),
|
||||
},
|
||||
// The CJS entry bypasses Vite's React Native alias and web-extension resolution.
|
||||
{
|
||||
find: /^react-native-gesture-handler$/,
|
||||
|
||||
Reference in New Issue
Block a user