Commit Graph
31 Commits
Author SHA1 Message Date
Mohamed Boudra 4f7813d3aa Keep the tapped line in place when chat disclosures expand or collapse, with the native timeline on LegendList (#8717)
* Keep a disclosure's trigger in place on web

Expanding or collapsing a turn summary, tool-call group, tool call details
or todo badge now holds the row that owns the trigger. Rows inside the
timeline get a disclosure context; each toggle runs its state change through
`useDisclosureHold()`, and the viewport's new `holdRow(rowId, apply)` replaces
`holdReadingPosition()`.

On web the hold pins the reading anchor to that row, stops following output,
and resumes following when the change leaves the list at its end. Native keeps
its previous behaviour (detach, then apply) until the anchored toggle lands.

* Render the native chat timeline with LegendList in forward order

The native timeline was an inverted FlatList. Disclosure toggles moved their
trigger, and a cold open settled visibly. It is now a forward-order LegendList
that stays virtualized, and one owner moves the scroll offset at a time:

- Before placement: LegendList's initial scroll, behind its hidden reveal.
- While following: the end pin (native-end-pin.tsx). This is a zero-height first
  child at the bottom of the content, which native maintainVisibleContentPosition
  keeps in place. A content shrink or growth never scrolls from JS; only a
  viewport resize or an explicit jump does.
- During a disclosure hold and while detached: LegendList MVCP with
  shouldRestorePosition (native-disclosure-hold.ts), so a toggle keeps its
  trigger at the same y and paints one layout change.
- Every newly mounted row starts at a per-kind estimate (new-row-estimates.ts).

PaseoScrollView defers React Native's content clamp while MVCP is set. A shrink
below an unmoved anchor no longer draws a frame past the end (RN #58578,
PR #58695). VisibleContentPositionClampTest covers this.

LegendList 3.7.1 patch, one "Paseo patch" comment per hunk:
- The content height comes from React state on the new architecture.
- Rows get a per-row size estimate.
- The MVCP anchor lock runs on native too. Upstream gated it to web in d24a9d93
  without a native reason. Without the lock, the size pass for prepended history
  re-anchors on a prepended row and moves the rows on screen. Native ends the
  lock when its row leaves the view, not on the 300 ms clock.
- Header and footer reveal together with the rows.
- Only lists that keep their end preserve the initial end.
- A remeasure within one pixel plus the 1/8 dp floor of the known size is
  noise. Upstream compares the raw size with the floored one, so the live
  turn's assistant row (1380 -> 1381 px) read as a resize and moved every row
  a frame after the reveal.
- The Android silent initial scroll retry sends its 1 dp nudge and the return
  to the target in one task. Upstream returns on the next frame, but its 16 ms
  check can finish the scroll first and cancel that frame, so the list revealed
  3 px short of its end and moved into place a frame later.
- The top-padding shrink hold applies on iOS only.

Android, emulator. Evidence is in .artifacts/legend-impl.
- J1, J2 at the end, J3, J4, J5, J6, J7: pass.
- J2 while streaming: not reachable on the Ten second stream fixture; its turns
  never fold.
- J8: no blank frames over 10 fresh flings, at host load 32-37.

J4: still. Before these two hunks, 6 of 7 cold opens moved every row one frame
after the reveal. After them, 9 valid cold opens show no shift. 5 of those
were traced through the nudge path. Runs that hit the dev client's startup
SIGSEGV in MountingCoordinator::pullTransaction were left out. That crash is
in the emulator's dropbox from before this work.

* Revise a history row when its turn summary line changes

LegendList renders a row again only when its item changes. The forward-order
timeline in d1f2ae352 folds a finished turn into its host user message, but the
host's item does not change when the fold appears, so a turn that folded live
lost its summary line until something else revised the row.

collapseTurns now reports the hosts whose summary line appeared, changed or went
away since the previous fold (updatedHostIds), and the history row revision
treats them as changed content.

* Stop following the end while a user scroll owns the viewport

The bottom anchor controller kept sticky-bottom for the whole of a drag and its momentum and only
decided at the end. While it is sticky the native list mounts the end pin, so a drag started during
a stream had every new line move the rows under the finger: on the iOS simulator the offset jumped
by each line's height (+22, +35, +22 pt) mid-drag, and Android does the same with the same code.

beginUserScroll now detaches. endUserScroll resumes following when the gesture ends at the end
(scrolling there without animation) and otherwise stays detached and drops any pending anchor, as
before. The re-stick is not animated: on Android a line that mounts during a smooth scroll cancels
it, and the list then followed 40 pt short of the end until the controller detached it.

Measured after the change, frame by frame:
- iOS and Android, drag up mid-stream: the content tracks the finger and momentum with no line
  steps; the stream grows below; tapping the pill re-sticks and follows again.
- A short drag at the end, mid-stream: following resumes; the release lands on the end in one
  frame (the drag distance, here 8 pt on iOS and 21 dp on Android).

A line already mounting when the drag begins can still move the rows once in its first frame,
because the detach reaches native one commit after the touch.

* Correct the iOS scroll view under maintainVisibleContentPosition

Phase 2 of the LegendList timeline. The native timeline relies on maintainVisibleContentPosition
for the end pin and the disclosure hold, and React Native 0.86 on iOS gets two cases wrong.
modules/paseo-scroll now registers PaseoScrollViewComponentView, a subclass of
RCTScrollViewComponentView, for the iOS scroll view, like PaseoScrollView on Android.

- A mount that removes its own anchor: detaching removes the end pin in the same mount that opens
  the tapped disclosure, and the offset jumped by the removed view's frame. The subclass keeps the
  offset when the anchor's tag is no longer registered, the check React Native 0.88 adds in
  https://github.com/react/react-native/pull/57294. That PR's extra superview check is not copied
  because it skips anchors detached by removeClippedSubviews
  (https://github.com/react/react-native/issues/58910).
- A shrink at the end: UIKit clamps the offset to the new end during the mount, then React Native
  adds the anchor's movement, so a turn folding while following landed above the end
  (https://github.com/react/react-native/issues/58578). The subclass restores the pre-mount offset
  for the adjustment and clamps after it, as
  https://github.com/react/react-native/pull/58695 does.

The dev client was rebuilt with the module. Verified on the iPhone 16 Pro simulator, frame by frame:
the summary far up and the latest turn's summary while following each keep the trigger at 0 px
with one layout change per toggle, the todo card the same, the cold open lands still, older
history pages keep the visible rows in place, and the keyboard, send-time shrink and streaming
stay pinned to the end.

Freshly mounted markdown on iOS paints one frame after its layout, because enriched-markdown
renders asynchronously (https://github.com/software-mansion/enriched-markdown/issues/1131). A
synchronous first render removed that frame but raised fling frame time at p90 and p99, so it is
not part of this change.

docs/agent-stream-disclosure.md now describes the clamp correction on both platforms.

* Keep the rows under the finger at a drag's start and its release

Two one-frame row movements remained around a user scroll while the timeline streams.

A drag's first frames. The end pin stays mounted until JS stops following, one round trip after
the drag begins, so a line mounting in those frames scrolled the rows under the finger by its
height. Both scroll view subclasses now skip the content-position correction while the user drags
and the anchor is the end pin, which they recognise by its native ID ("paseo-end-pin"). Android
overrides scrollToPreservingMomentum, the position keeper's only scroll call, from the begin-drag
event until the finger lifts; iOS skips the adjustment while the scroll view is dragging. The skip
is limited to the pin: LegendList's own anchoring for the detached list also uses the native
correction, and a history page landing mid-drag needs it. It starts at the drag rather than at
touch-down: a tap or long press while following would otherwise leave the list stranded short of
its end, still following, once the finger lifts.

The release. A gesture ending within 32 pt of the end re-stuck with an instant scroll, a snap of up
to 56 px. Following now resumes only when the finger lifts at or past the end, or momentum stops
there, within 1 px, the distance web resumes at (strategy-web.tsx). Anything else stays detached with the
pill. The check runs when the gesture is decided, so output that arrived below meanwhile counts. A
release past the end counts as at the end: on iOS it bounces back, and a line landing during the
bounce scrolls in as following would. endUserScroll takes isAtEnd, like releaseDisclosure.

Tests: VisibleContentPositionClampTest gains growth at the end during a drag (red before the
change: the offset moved by the 120 px growth, 352 -> 472) and growth without a touch (the pin
still follows); the module suite passes on emulator-5558, 11 tests.
bottom-anchor-controller.test.ts: a release near but short of the end stays detached.

Device, frame scans while streaming (dev clients rebuilt):
- Android, 8 slow drags with a pill tap between: no frame moves against the finger once a drag
  starts; the stream follows between drags. Three short releases 50 px short of the end stay put
  until the pill; three drags back past the end resume following.
- iOS simulator, 6 drags: no line step during any drag or its momentum. Two short releases stay
  detached with no snap; three drags past the end resume following within 0.5 s of the bounce.

* Document the LegendList timeline and remove what the inverted list left

The native section of docs/agent-stream-disclosure.md described the FlatList hold (anchor
neighbour, native child index, frame spacing, render window). It now covers why the timeline is
forward order, who owns the offset in each state, the hold and its sync layout trigger, user
scroll and release, draw distance numbers, the LegendList patch and the scroll view corrections.

Draw distance stays at 2000. The spike measured it on a chat with a 15,000 px turn expanded: 500
blanked the viewport 1.0 to 1.5 s per fling and 2000 not at all. A re-measure on the collapsed chat
(500, 1000, 1500, 2000, interleaved) blanked at no value and its jank followed host load, which
stayed between 19 and 40, so it could not separate 1000 or 1500 from 2000.

The patch file now opens with a header listing its eight hunks by purpose and linking
LegendApp/legend-list#593, which reports each one upstream. The markdown doc records the iOS
first-paint delay (software-mansion/enriched-markdown#1131).

Removed with the inverted list: PaseoScrollView's child-reveal override (a no-op without the
inverting transform) and ScrollSelectionTest, the invertedWrapper style, and the inverted-list
passages in android.md, native-text-selection.md, mobile-testing.md, floating-panels.md and
agent-stream-performance.md. model.test, layout.test and footer-spacing.test still asserted the
inverted native order and failed; they now assert forward order, and footer-spacing uses the
real strategies instead of an inverted stub.

* fix(app): show a reply's footer when the next prompt arrives on native

LegendList re-renders a mounted row only when its item or extraData changes.
When a new prompt arrives, the previous reply's footer moves from the live
auxiliary slot into that reply's layout item (completedFooter). The reply's
StreamItem keeps its identity, and renderItem is a stable event, so the row never
rendered its new layout and the footer (copy, timing, fork) disappeared until the
row remounted. FlatList had the same gap.

The history renderer is now LegendList's extraData. Its identity changes with the
history layout map, and StreamRow's memo limits the pass to rows whose layout
item changed.

* test(app): run the web disclosure hold cases in a real browser

The three hold cases simulated layout in jsdom: hand-written row heights,
scroll clamping and resize callbacks. They could pass while Chromium paints
the trigger somewhere else. They now run in Vitest browser mode with real
layout, ResizeObserver and scroll clamping. The two hold cases fail when the
hold is bypassed.

The browser project pre-bundles @tanstack/react-virtual, so its first import no
longer reloads the test mid-run. The lucide stub gains ArrowDown, which
pane-find imports.

* fix(app): let a drag end a disclosure hold on native

A drag detached the bottom controller but kept the disclosure hold. Every item
size change restarts the hold's quiet frames, including a streaming row's, so
the hold can outlive the toggle. While it lasts, shouldRestorePosition accepts
only the held row. Once the drag scrolls that row away, a history page arriving
mid-gesture gets no anchor, and the rows the reader was on move by the page's
height.

The drag now cancels the hold without releasing it. The controller keeps no hold
state of its own beyond detached, and the gesture's end decides whether following
resumes.

* perf(app): keep the scroll-to-bottom button's state out of the timeline

Opening a tool call or turn at the end of the chat now holds its trigger in
place, so the timeline detaches and the scroll-to-bottom button appears. That
visibility was useState in AgentStreamView, so showing the button re-rendered
the whole timeline view for a change only the button reads. The viewport now
writes near-bottom into a per-agent store that only the button subscribes to.

* fix(app): show the scroll-to-bottom button on native whenever following has stopped

A gesture now resumes following only when it ends within 1 px of the end, but
the button still hid within 32 px. A release 2-32 px short of the end left the
list detached with no button to return. While detached the button now hides
only at the end; while following it keeps the 32 px tolerance so content
growth that the offset trails for a frame does not flash it. The button also
updates when following starts or stops without a scroll.

* fix(app): stop the timeline jumping when the keyboard opens or closes on native

The keyboard's settled height was committed to the list after the motion, as
paddingTop on Android and contentInset.top on iOS, to keep the top of the
history reachable under the translated dock. While following, the end pin
absorbed it in the same mount. While detached or holding a disclosure, nothing
did: Android's native anchor is LegendList's ScrollAdjust view, which padding
does not move, and LegendList compensates a padding change only on iOS. Every
row jumped by the keyboard height 0.2-1 s after the keyboard settled, and back
after it closed. On send, the same commit raced the jump to the end.

The keyboard now only translates the list with the composer. The cost: while
the keyboard is open, the composer viewport clips the top keyboard-height of
the list, so the start of a short conversation is hidden until it closes.

* chore(app): describe the native timeline as LegendList in stream comments

* fix(app): keep a disclosure's trigger in place when its row is cut off at the top on native

LegendList's idsInView starts at the first fully visible row. When a turn host's prompt bubble
sat above the viewport, a disclosure hold found no anchor in the data pass (shouldRestorePosition
rejects every other row), and the size pass anchored on the row below the host instead, so the
trigger moved by the host's size change. The patch appends the partly visible rows to idsInView
after the fully visible ones, so the held row anchors while every pass that does not restrict the
anchor keeps the first fully visible row.

The browser test renders the patched native build, which vitest now aliases instead of the
package's unpatched web build.

* fix(app): let a turn's summary line collapse again after it opens

The React Compiler cached StreamRow's `renderStreamItem(layoutItem)` on the layout item and the
renderer. The revised item that useRevisedHistoryRows hands a row whose display state changed only
fed the row id, so the row reused its cached content. Opening a turn usually inserts its rows
directly below the host, which also gives the host a new layout item. When another row sits
between them, such as a compaction marker, the host kept its layout item, its summary line stayed
collapsed, and every later tap asked to open the turn again.

The renderer now takes the item it renders, so the compiler's cache keys on the revision. StreamRow
moves to its own module, which the tests compile the way Expo does.

* test(app): alias the patched LegendList build in browser tests only

Unit tests load @legendapp/list outside Vite, so the native build's react-native import skipped
the react-native-web alias and reached React Native's Flow source, failing every unit suite that
imports the native strategy.

* fix(app): keep a size-only disclosure's trigger in place when its row is cut off at the top on native

LegendList's prepareMVCP picks the anchor for a size change with no data change as the first row
in view, without asking shouldRestorePosition. A disclosure that only grows its own row (a todo
card) while that row's top was above the viewport therefore held the row below it, and the trigger
moved by the row's size change. The patch now applies shouldRestorePosition to size changes as it
already does to data changes.

* test(app): render the timeline row revision test in a real browser

The repo's unit tests do not mount components in JSDOM. The test still compiles stream-row.tsx with
the React Compiler, so it fails when a revised row ignores the item it was handed.
2026-11-04 17:21:31 +07:00
Mohamed Boudra d6d5a266ad Open a subagent in place of the subagents list on mobile (#8093)
* fix(app): seat the subagent sheet's parent callout like the composer

The sheet tab body padded the bottom safe-area inset, and the composer dock
padded it again, so the parent callout in a subagent sheet floated a full
navigation-bar height above where the composer sits in a tab.

The sheet body (SheetBottomInset) now owns the inset: it pads it once and
tells its content none is left, so the dock stays host-agnostic. Overlay
sheets (adaptive modal sheet, menu sheet, combobox sheet) read the inset on
the far side of their portal, so ones opened from sheet content still clear
the screen edge.

The callout takes the composer's rail and shows the parent's provider icon
through StatusIcon: no icon box, with the running ring, attention dot or
needs-input alert on the icon.

* fix(app): give the attachment lightbox its own safe area

An RN Modal passes its call site's context straight through, so a lightbox
opened from a sheet tab read the bottom inset the sheet body had already
spent (0) and drew over the navigation bar. The modal is its own window:
it now gets its own SafeAreaProvider, which measures that window, and the
image area reads its insets below it.

floating-panels.md records the rule for overlays: read insets where the
overlay draws.

* feat(app): open a subagent in place of the subagents list on compact

On phones, choosing a subagent from the subagents list now replaces the list
inside the same sheet instead of stacking a second sheet over it. Back returns
to the list, Close closes both, Android Back steps subagent -> list -> closed,
and Open parent lands on the pane the list was opened from (for a nested
subagent, its parent's sheet).

- Menu engine: fill pages (`fill: true`) take the full sheet height, Back pops
  a page via `goBack`, and a page whose definition disappears falls back to
  the list.
- One sheet pane (`screens/workspace/sheet-pane`) renders the header, body,
  bottom inset and focus for both containers: the workspace's detached sheet
  tabs and the in-list menu page. Only the container differs.
- The header drags the sheet through `SheetDragArea`; compact menu sheets use
  the shared `SheetBackground`/`SheetHandle` chrome.
- `selectSheetTab` describes the sheet a reveal would show without opening it.

* fix(app): put the parent callout on one row

The Open parent button already says what the callout is, so the Parent label
goes. The parent's title shrinks to one line before the button does, so Open
parent never wraps or leaves the row.

* refactor(app): register panels once in the workspace screen

Every reader of the panel registry called ensurePanelsRegistered, which
imports every panel module. The panels render those readers (the agent panel's
subagent track and parent callout, the new-tab panel's launcher, the diff
panel's review surface), so each reader closed an import cycle through
register-panels.

The workspace screen, the only route that renders a registry reader, now
registers the panels before anything under it renders, and the readers import
only the leaf panel-registry. The pane content test registers a probe panel for
real instead of mocking the registry.

* test(app): transform the markdown WASM factory when Vite versions its URL

Importing the sheet pane barrel in retained-chat-content.browser.test.tsx made
Vite request md4c.js with a ?v= query, which the CommonJS-to-ESM transform did
not match, so the test file failed to import.
2026-10-28 13:55:41 +07:00
Mohamed Boudra 582beeb10b fix(app): preserve compact sidebar context under sheets (#7693)
Keep compact sidebars open while users inspect Search, Import session, Add project, or file sheets. Reveal the resolved destination when navigation commits, and place compact workspace sheets above both sidebars while preserving shared editing and cache state.
2026-10-23 15:57:12 +07:00
Mohamed Boudraandsam 99a1d9a7a2 fix(app): keep the iPhone skill picker above the composer and keyboard (#7679)
Keep iOS command and file suggestions above the composer and keyboard across chat, workspace drafts and New workspace. Share transform-free anchor measurement and keyboard translation through keyboard/shift.

Includes failing-first native placement and exact-insertion coverage, plus two CI test reliability fixes.

Co-authored-by: sam <76036008+masz77@users.noreply.github.com>
2026-10-23 11:41:00 +07:00
Mohamed Boudra e522e6ea8e fix(app): make the compact Explorer tab rail usable on phones (#7589)
Popovers on Android landed one status bar below their trigger. React Native
0.86 stopped subtracting the status bar from window coordinates under
edge-to-edge, so measureInWindow, touch pageY and a full-screen Modal already
share screen-top coordinates; the old StatusBar.currentHeight compensation in
the menu engine, tooltip, context menu and sidebar long-press now
double-shifted every anchor.

Compact tabs keep their natural width and the rail scrolls as soon as they
overflow, instead of squeezing labels first and then scrolling anyway. They
drop the inline close because the long-press sheet already closes a tab and a
narrow X beside the label is easy to hit. Any scrolling rail keeps natural
widths rather than collapsing every chip to the minimum, and the rail resets
its offset when scrolling ends so tabs scrolled out of view come back.

The Explorer rail paints the sidebar surface, so its chips climb the sidebar
hover and selected tokens. Light themes paint the sidebar with surface2, which
made the surface2 active chip invisible there.

The compact Explorer closes with the same X as the compact agent list, and the
compact + menu omits Diff because compact Changes already shows diffs inline.
2026-10-21 21:44:53 +07:00
Mohamed Boudra 6017706cc1 fix(app): avoid restoring composer focus on touch navigation (#7130)
Limit implicit pane and remount focus replay to the existing fine-pointer policy. Fullscreen keeps one editor mounted, so touch navigation does not need the restoration introduced for the former portal handoff.
2026-10-16 17:27:06 +07:00
Mohamed BoudraandClaude e5124b039a Put sheets on one stack and show an agent's facts in its sheet (#6958)
* Put sheet tabs on the one sheet stack

Sheets are one primitive with one stack. A sheet opened from any sheet
stacks on top of it, and closing it uncovers the sheet underneath. The
compact workspace's file and subagent sheets were an in-tree Gorhom
BottomSheet with their own stacking, so a subagent chosen from the
subagents list sheet replaced the list instead of covering it.

- SheetHostScope: a subtree hosts the sheets presented inside it, so the
  sheet tabs draw inside the compact column and read the workspace
  providers while still joining the app-wide stack. Needs Gorhom's
  BottomSheetModalInternalProvider, which the patch exports.
- IsolatedBottomSheetModal takes enableDismissOnClose; false keeps the
  content mounted through a close so the unsaved-changes guard can veto
  it. Its backdrop then closes the sheet rather than dismissing the top
  modal. The Gorhom patch also keeps a dismissing modal's status through
  its final onChange(-1), without which such a modal unmounting while
  presented never left the portal.
- SheetTabs (was WorkspaceSheetHost) renders one IsolatedBottomSheetModal
  per store entry. The store never reorders sheets; a reveal of a covered
  sheet drops the unmodified sheets above it.
- Subagent rows that open a sheet keep the list open (closeOnSelect
  false); rows that open a tab still close it.
- A menu closes when its retained panel goes inactive, instead of hiding
  and reappearing with the panel on the next tab switch.

Co-Authored-By: Claude <noreply@anthropic.com>

* Show an agent's model and thinking in its sheet, and fit Open as tab in the header rail

A Paseo agent shown in a sheet has no composer, so the facts the composer's
model and thinking controls would show now sit in one line under the sheet
header, the same row provider subagents already get from the daemon. The line
resolves exactly as the controls do (resolveAgentModelSelection), from the
session agent and the providers snapshot, and shows wherever the pane host is
a sheet.

Open as tab becomes the full-screen glyph beside Close, drawn by the shared
SheetHeaderIconButton, so the title keeps its width. The tab switcher sheet is
titled "Workspace tabs" so it reads as what it lists.

Co-Authored-By: Claude <noreply@anthropic.com>

* Show mode and context usage in the sheet facts line

The line under a sheet's header now carries everything the composer's
controls and context meter show: model, thinking, mode, and used/max context
tokens with the share, each resolved as its control resolves it.

Co-Authored-By: Claude <noreply@anthropic.com>

* Re-render subagent rows when the workspace's tabs change

A row decides at render time whether choosing it opens a sheet or reveals
a tab, and keeps the list open only for a sheet. The hook behind it only
subscribed to the layout mode, so a row rendered while its agent was a
full tab kept closing the list after that tab was gone: on iOS, promoting
a managed child, closing its tab, and reopening it from the list dropped
the list under the sheet. Subscribe to the workspace's layout too.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 12:16:45 +07:00
Mohamed Boudra 6db47b3c37 fix(app): keep the keyboard closed after mobile sidebar dismissal (#6945)
Separate composer keyboard eligibility from pane focus restoration. A sidebar dismisses the keyboard without hiding the pane, so its close must not restore editing focus.
2026-10-15 08:59:49 +07:00
Mohamed Boudra f3ddf99fe3 fix(app): restore fullscreen composer keyboard motion and clearing (#6902)
* fix(app): keep fullscreen composer in the keyboard dock

* fix(app): preserve composer caret and scope keyboard actions
2026-10-15 06:30:13 +07:00
Mohamed Boudraandpaseo-bot[bot] e135e9c101 Open files and subagents in a closable sheet on phones (#6827)
* feat(app): open files and subagents in a closable sheet on compact

On phones, opening a file, a provider subagent, or a Paseo subagent now shows it
in a bottom sheet over the current tab instead of adding a tab, so the tab strip
stays for terminals and root agents. Paseo subagents get "Open as tab" to move
the same tab (same tabId and state) into a full tab with a composer; agents in a
sheet hide the composer.

The decision lives in the workspace layout store's openTab, driven by a
per-kind `compactSheet` rule in the panel manifest, so every open path (chat
links, Explorer, subagents track, notifications, plugins) follows it without
call-site branching, and a new kind opts in with one manifest entry. The app
shell publishes the layout mode (compact / single-pane / split-panes) once, so
callers stop passing isCompact for placement.

A sheet holding unsaved edits is never implicitly displaced; later opens go to
tabs underneath, and closing it goes through the existing discard guard. The
editor host is position: sticky because CodeMirror's scrollIntoView otherwise
scrolls the bottom sheet's container and drags the sheet to full height.

* test(app): fail the compact sheet snap check when the sheet is oversized

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-14 00:20:10 +07:00
Mohamed Boudra fa0f04058d Keep account usage accessible before the first turn and restore compact sheets
* fix(usage): keep details hoverable and restore compact sidebar sheets

Share hover-card mechanics with workspace details so context usage can stay open while users press Refresh. Honor Retry-After for rate-limited usage requests, and let both sidebar entry points share the compact sheet.

* fix(usage): restore settings cog with a fitted popover and compact sheet

Keep shared Usage preferences in the title controls on every surface. Settings rows use the menu surface directly, avoiding the trailing margin and duplicate card border from the page layout.

* fix(app): show account usage before the first agent turn

Keep the context meter available for existing agents without context readings so account usage can be opened before a turn. Show an empty ring and an explicit no-data state until readings arrive.

* fix(usage): preserve keyboard access and hover card positioning

Context details must remain reachable by keyboard after replacing the tooltip. Keep focus within the card, restore side flipping, and dismiss detached cards when the viewport moves. Prune expired cooldowns even after credential rotation.

* chore: remove QA screenshots from the source tree

* fix(app): let hover cards claim Escape before agent shortcuts

Register hover surfaces with the shared overlay keyboard owner without moving or trapping focus on pointer hover. Component-local Escape handling ran after the global interrupt shortcut and cancelled the underlying turn.

* fix(app): reopen focused hover cards with button keys

Keep focused triggers registered with overlay arbitration after dismissal so activation keys are handled before composer shortcuts.

* fix(app): release overlay ownership when hover cards close

Closed triggers handle activation locally, so a dismissed card does not block pane Find. Open cards retain shared Escape arbitration to protect running turns.

* test(app): pin Linux keyboard identity for composer Find

* test(app): synchronize context usage hover journeys

Return focus to the composer before asserting pointer dismissal, wait for the card to reach the viewport, and park the pointer before reloading so it cannot consume the next scripted usage response early.
2026-10-05 01:59:15 +01:00
Mohamed Boudra e939dd4c7a Streamline sidebar usage and stacked sheet interactions (#5805)
* feat(app): streamline sidebar usage and stacked sheets

* fix(app): restore compact sidebar navigation clicks

The close button container emitted box-none through Unistyles as invalid CSS and intercepted New workspace clicks. Let React Native apply click-through behavior at the view boundary. Keep the import journey on its existing New workspace screen and assert only the visible transcript.

* fix(app): cap menu sheets at eighty percent height

Keep long sidebar usage reports recognizably sheet-sized while allowing the content to scroll. Short menu sheets retain dynamic sizing.
2026-10-01 04:55:17 +01:00
Mohamed Boudra 6830c46e9a 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.
2026-09-16 23:43:15 +02:00
Mohamed Boudra 0e8a71a5fe fix(app): stabilize native composer sizing after edits (#4946)
* fix(app): stabilize native composer sizing after edits

* test(app): verify workspace composer space after keyboard dismissal

* test(app): start the hold-to-delete regression mid-draft

From the end of the draft the pre-fix input also returns to baseline. Placing the cursor
eight lines into the draft before the first hold-delete leaves the old build at the
capped height with the placeholder visible, and the fixed build at one line.
2026-09-16 15:51:22 +02:00
Mohamed Boudra 7557992dff fix(app): stabilize composer height and isolate typing updates (#4902)
* fix(app): preserve composer capacity when the keyboard closes

Releasing the keyboard reservation enlarged an unchanged capped draft. Retain the last editing reservation while still recalculating capacity for viewport changes and subsequent keyboard openings.

* perf(app): isolate composer editing and resize updates

Draft and cursor subscriptions rerendered the chat and full composer on every native edit. Give editing consumers a live text source, preserve attachment identity on text updates, and read viewport settling state without a render.

Read live input text for web resize measurement so removing per-keystroke props does not collapse a draft when its width changes.

* fix(app): restore current draft when the composer remounts

The last replacement command does not include subsequent edits. Seed remounted editors from the live draft snapshot so switching launch modes restores the saved text without subscribing the composer to every edit.

* fix(app): synchronize composer resolution state before paint

Avoid an extra passive-effect delay when forwarding forge resolution state from the isolated binding. Exercise live-text width measurement through the actual composer E2E journey instead of a hook-only test component.
2026-09-16 00:05:19 +02:00
799c91b39d fix(app): restore Android tablet model picker interactions (#4845)
Restore provider/model selection in Android tablet modal windows by giving both native modal hosts their own gesture-handler root. Preserve phone sheet and web portal behavior.

Include native regression scripts, test dependency resolution, and verified CI test corrections. Generated media and logs are excluded. Refs #4475 and #4377; consolidates #4721 and #4771.

Co-authored-by: Mike P. Kuhl <mkuhl@softmachine.at>
Co-authored-by: Cameron Chin <cjcrjc@users.noreply.github.com>
2026-09-14 19:10:32 +02:00
Mohamed Boudra d1a53255fc fix(app): keep growing composers below the header (#4824)
* fix(app): keep growing composers below the header

Native keyboard translation left the dock at full height, so intrinsic composer growth could extend behind the header. Reserve the keyboard destination at motion boundaries and bound the composer to its stationary viewport while preserving native input scrolling and animation.

* docs: reuse existing test suites and automation
2026-09-14 12:25:29 +02:00
Mohamed Boudra bb676ef086 Smooth mobile keyboard transitions (#4275)
* perf(app): smooth mobile keyboard transitions

Animating dock padding forced Fabric and Yoga to relayout the chat tree on every keyboard frame, producing visible stepping and React list churn. Keep the surface full-height and move it as one translated unit, with scroll range and portal measurements updated only after keyboard motion settles.\n\niOS keeps its native keyboard animation driver behind the same component boundary because its Reanimated signal reports the endpoint at animation start.

* fix(app): isolate native keyboard implementation
2026-09-03 20:27:19 +02:00
Mohamed Boudra 6c813d1a91 fix(app): keep chat synchronized with keyboard (#4044)
* fix(app): keep chat synchronized with keyboard

Dock the complete chat surface with worklet-driven layout padding so visual and native touch geometry move together. Reset cleared multiline native inputs and keep autocomplete portals anchored to the docked composer.

* fix(android): tolerate detached composer inputs

Return the nullable base input connection during IME restart instead of force-unwrapping it after the composer has blurred or unmounted.
2026-08-29 21:27:41 +02:00
Mohamed Boudra a361d1cb2f fix(app): polish workspace navigation surfaces
Use mobile action sheets and long-press file menus while preserving provider context across sheet portals. Keep sidebar and file-pane glyphs on shared visual rails, avoid scrollbar layout shifts, and hide keyboard affordances where shortcuts are unavailable.
2026-08-03 21:40:21 +02:00
Mohamed Boudra ab8151d33f feat(app): sidebar display preferences, a shared menu engine, and a colour pass
Sidebar polish round.

Menus. `dropdown-menu` and `context-menu` were two copies of one engine that
differed only in how the trigger fires — identical from `computePosition` down —
and neither could nest. Both are now thin wrappers over
`components/ui/menu/`, which owns anchoring, a pure page-stack navigation model,
and one presentation fork: flyout on pointer, pushed pages in a sheet on compact.
All 31 call sites are unchanged. Items got a rounded inset fill, a visible
separator, and `selected` (a check) split from `active` (a fill).

Sidebar display preferences. The flat wall of four sections becomes one row per
decision with its value, and per-feature toggles over what a workspace row shows:
host, pull request, checks, scripts. Each is independent — a ticked toggle that
draws nothing is a lie. `display-preferences/` hides that grouping lives in
zustand while title source and row items are app settings.

Timestamps. "Last activity" now ticks itself. The label rounds to the minute and
never shows seconds; past a week it becomes a date. The timer lives in the leaf
`<Text>`, at most one per resolution tier shared by every row, and a tick only
re-renders when the string actually changed — so a memoized row that legitimately
never re-renders still stays current.

Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as
the muted tier, lightness and hue untouched. File icons are pulled toward neutral
in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a
tree where the icon is the only colour on the row, and ours is not. New/deleted
files use the forge square-plus/square-minus glyphs at muted weight instead of
tinted pills. Project icons round a little more; uploaded icons never do.

The project status badge is a knockout again: it fills with the colour of the row
behind it, so the ring reads as a gap in the icon rather than a white halo.

File rows. The always-visible kebab is gone from the file explorer and diff tree
in favour of right-click, which also reclaims the reserved action slot.
2026-08-03 18:58:06 +02:00
Mohamed Boudra 2acb10fce9 Keep provider settings above the model selector (#2476)
* fix(app): keep provider settings above model selector

Desktop web comboboxes used the browser top layer, so ordinary modal portals could not cover them. Keep web overlays in one relative layer stack while preserving native bottom-sheet stacking.

* fix(app): route overlay keyboard ownership

* fix(app): preserve nested overlay ownership

* fix(app): route command center overlay input

* fix(app): preserve global overlay ancestry

* fix(app): register global dialog hosts
2026-07-27 14:04:29 +02:00
Mohamed Boudra fc10c79e26 fix(app): synchronize chat submission and keyboard state
Render optimistic turn feedback before host acknowledgement and roll it back on rejection. Reconcile iOS keyboard offsets from the native transition end event so JS contention cannot leave the composer displaced.
2026-07-24 11:52:31 +02:00
Mohamed Boudra 8aa55db1e8 feat(app): improve workspace service controls
Keep service route choices independent of daemon transport and persist the selected route per host. Use the shared web overlay stack so menus, tooltips, and toasts layer predictably.

Track proxy route changes from the Git branch rather than the workspace title to avoid transient service health changes.
2026-07-21 13:44:23 +02:00
Mohamed Boudra d9b65e269f Scheduled runs each get their own workspace in the sidebar (#1934)
* feat(schedules): per-run workspaces, isolation and archive controls

Scheduled agents never appeared in the sidebar: each schedule reused one
stamped workspace and archived the run's agent on completion. Now every run
mints its own workspace — the entity the sidebar shows — with per-schedule
controls for isolation (local or worktree) and whether to archive that
workspace when the run finishes.

The schedule form is rebuilt on a non-React form model (open/commands/close)
to end the flicker, edit-into-create contamination, and multi-host hydration
bugs that came from effect-choreographed shared state. That lands the reusable
foundations it needed: a form kit over a single control-geometry owner, a
data-access taxonomy (replica/snapshot/fetch) with real load-state semantics,
and lint/boundary guardrails so new screens inherit the paved road.

Also fixes desktop combobox popovers truncating options when the trigger is
narrower than the content. New schedule protocol fields are optional on both
create and update, so old and new clients/daemons stay compatible.

* test(app): complete mock themes for control-geometry tokens

switch and terminal-profile-edit-modal now style through
createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50],
and theme.colors.borderAccent. The hand-rolled mock themes in these two
suites lacked those tokens, so the eager StyleSheet.create mock threw at
import (collection error), not on any assertion. Add the missing tokens.

* fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration

- Server: archive the run's workspace even when agent creation fails, so a
  misconfigured provider no longer orphans a sidebar workspace/worktree.
- Edit form: keep a stored worktree isolation while worktree eligibility is
  still resolving, instead of silently downgrading a saved schedule to local.
- Schedules screen: show the load error/retry UI when every host fails, rather
  than spinning forever behind the loading branch.
- Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the
  sheet closes instead of re-opening.
- Form model: apply late-loading saved preferences to untouched create-mode
  fields (never to edited schedules or user-modified fields).

* fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety

- Push-router: re-send terminal and checkout-diff subscriptions after a
  reconnect; the old per-hook effect resubscribed on isConnected and the
  extracted router did not, so daemon restarts silently stopped terminals_changed
  pushes (root cause of the terminal-activity e2e failures).
- Server: pass the run's source repo root when archiving worktree runs so the
  worktree is unregistered (git worktree remove/prune), not just deleted; also
  archive the workspace when agent creation fails even with archive-off (an
  agentless workspace has nothing to inspect).
- Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the
  Android native path too; replace the JSDOM component test with a real
  Playwright dismiss spec and inline the trivial dismiss decision.
- Form model: editing an interval schedule no longer rewrites its cadence to
  cron unless the cadence UI is touched (90-minute intervals have no cron
  equivalent); switching projects clears stale provider/model state and gates
  submit until the new host's snapshot resolves.

* chore(lint): drop custom lint wrapper and boundary script, plain oxlint

The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports).
The receiver-sensitive checks the custom script enforced are not worth a
parallel lint pipeline; if they come back it will be as oxlint rules.

* fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations

- Persist the run's workspace/agent ids on the running-run record so a daemon
  crash mid-run can be recovered: restart archives the interrupted run's
  workspace under the same policy as normal cleanup (agentless always archives,
  otherwise archiveOnFinish is respected).
- Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on
  hosts without workspaceMultiplicity — an old daemon ignores the fields, so
  offering the switch there would lie.
- Optimistic pause/resume/delete no longer throw when the schedules cache holds
  a still-connecting entry alongside loaded data.
- Mode field is gated on provider mode options, not a selected model, so
  model-less providers can pick their advertised modes.
2026-07-07 22:13:42 +02:00
Mohamed Boudra 4e899c644a Keep keyboard offsets tied to visibility
Derive app keyboard offsets from one provider-owned shared value so chat content, composer surfaces, and floating panels share the same policy.

Treat progress-zero keyboard samples as closed, even if Android reports a stale nonzero height.
2026-06-18 12:03:44 +07:00
Mohamed Boudra dd329a4f52 Fix stacked provider settings sheets 2026-06-05 21:15:44 +07:00
Mohamed Boudra eea5932a21 Fix mobile bottom sheets reopening after dismiss 2026-05-30 09:43:49 +07:00
Mohamed Boudra c31ab074e9 Fix autocomplete popover anchoring
Position portal content relative to its render host so the composer anchor stays correct when surrounding layout shifts. This preserves the Portal surface needed for Android scrolling and touch behavior.
2026-05-26 11:17:03 +07:00
Mohamed Boudra 22e014aaff Fix mobile autocomplete sidebar layering 2026-05-26 10:02:27 +07:00
Mohamed Boudra 008e4e846f fix(app): make slash command popover interactive on Android
The popover was positioned `bottom: 100%` of its parent inside the composer
and worked on iOS/web; Android's hit-test by parent bounds drops touches on
children that overflow their parent, so taps and scrolls went through to the
chat behind it. Move the popover into a `<Portal>` at the app root and apply
the same Reanimated keyboard SharedValue as the composer so it tracks the
keyboard in lockstep. New `docs/floating-panels.md` captures the gotchas
(Android hit-test, Portal lifecycle/transforms, status-bar offset, the
two-measurement flash) and the canonical files.
2026-05-22 20:44:37 +07:00