diff --git a/CHANGELOG.md b/CHANGELOG.md index fc0dc45092..10cfa1ef09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.15.2 - 2026-11-05 + +### Added + +- Added a first-run setup on Paseo Desktop and web that walks new users through theme, providers, projects, agent orchestration and the mobile app, with Settings → General → Show setup again to reopen it ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Added project suggestions from the folders where you ran Claude Code, Codex, OpenCode or Pi, shown in the setup Projects step ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +### Improved + +- Changed the theme picker to a grid of tiles with a preview of each theme ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Replaced the "Loading…" text in Providers settings with skeleton rows ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed controls to two sizes: default controls are 32px tall on desktop and 44px on phones, and compact controls are 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed first launch to go from the loading screen straight into setup instead of showing the empty home first ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- Changed dialogs on desktop and web to fade in over about 160ms ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- Changed modal and sheet backgrounds to match the screen background ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- Changed documentation links in Settings sections to a `Label ↗` link beside the section title ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +## 0.15.1 - 2026-11-05 + +### Fixed + +- Fixed the daemon failing to start when the terminal worker cannot read its own process identity, which left Paseo Desktop without a daemon on Windows machines where Windows PowerShell 5.1 cannot run `Get-CimInstance` ([#8810](https://github.com/getpaseo/paseo/pull/8810)) + ## 0.15.0 - 2026-11-03 Daemon restarts now keep terminal programs running. Interrupted agents can resume in their existing conversations when you turn on agent recovery. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index e70a2f92da..5f0156eb0e 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -1,5 +1,28 @@ # Changelog +## 0.15.2 - 2026-11-05 + +### 新增 + +- 在 Paseo 桌面版和网页版新增首次运行引导,依次带新用户设置主题、提供商、项目、Agent 编排和移动端应用,并可通过“设置 → 通用 → Show setup again”重新打开 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 在引导的“项目”步骤中,根据你运行过 Claude Code、Codex、OpenCode 或 Pi 的文件夹推荐项目 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +### 改进 + +- 将主题选择器改为带各主题预览的图块网格 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将“提供商”设置中的“Loading…”文字替换为骨架占位行 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将控件统一为两种尺寸:默认控件在桌面端高 32px、在手机上高 44px,紧凑控件高 28px ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将首次启动改为从加载界面直接进入引导,不再先显示空白的主页 ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- 将桌面端和网页版的对话框改为约 160 毫秒的淡入 ([#8824](https://github.com/getpaseo/paseo/pull/8824)) +- 将弹窗和面板的背景改为与屏幕背景一致 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) +- 将“设置”各分区的文档链接改为分区标题旁的 `Label ↗` 链接 ([#8363](https://github.com/getpaseo/paseo/pull/8363)) + +## 0.15.1 - 2026-11-05 + +### 修复 + +- 修复终端工作进程无法读取自身进程标识时 daemon 无法启动的问题;此前在 Windows PowerShell 5.1 无法运行 `Get-CimInstance` 的 Windows 机器上,Paseo 桌面版因此没有可用的 daemon ([#8810](https://github.com/getpaseo/paseo/pull/8810)) + ## 0.15.0 - 2026-11-03 daemon 重启后,终端中的程序继续运行。开启 Agent 恢复后,被中断的 Agent 会在原有会话中恢复工作。 diff --git a/CLAUDE.md b/CLAUDE.md index c9b6e0db7c..1496bd407b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage | | [docs/terminal-rendering.md](docs/terminal-rendering.md) | Native terminal view: xterm owns the grid, cluster placement, emoji widths, mixed-direction limits, render regression | | [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render | +| [docs/agent-stream-disclosure.md](docs/agent-stream-disclosure.md) | Expand/collapse in the chat timeline — hold contract, native anchor sequence, frame spacing, render-window cost | | [docs/markdown-renderer.md](docs/markdown-renderer.md) | Shared Markdown engine, native selection, links, images and library constraints | | [docs/native-text-selection.md](docs/native-text-selection.md) | Native diagram grouping, selection boundaries and leading inline image limitations | | [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison | diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index d8ee067c6c..45c54ca18b 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -136,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it. +Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`. +This is informational ancestry for future workspace grouping. It does not change placement, +visibility, status, or archive behavior. Archiving a parent workspace preserves its children's +records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is +never inferred from directories, projects, or Git branches. + Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility. @@ -305,9 +311,9 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. -A finished workspace can be marked unread after it has been reviewed. The daemon restores -`finished` attention on its newest eligible workspace-root agent without sending a new completion -notification. Opening the workspace clears that attention through the normal focus flow. +A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores +attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is +`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow. Attention is set by the agent finishing or failing and cleared by the client's `workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is @@ -316,6 +322,13 @@ untouched. Forging either makes a background resume look like the user read a wo agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted `updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start. +The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error` +until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the +green one. For the same reason every new failure raises `error` attention, including a failed reload +that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed +agent would show the green dot or none. Subagents track marks are the exception: they follow the +child's lifecycle, because the track is where a failed child stays visible. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. diff --git a/docs/agent-stream-disclosure.md b/docs/agent-stream-disclosure.md new file mode 100644 index 0000000000..9cde8a44ff --- /dev/null +++ b/docs/agent-stream-disclosure.md @@ -0,0 +1,163 @@ +# Agent stream disclosures + +Expanding or collapsing something in the chat timeline (a turn's summary line, a tool-call group, +a tool call's inline details, a todo card) keeps the tapped trigger at the same screen y in the +first painted frame. No animation. + +## The contract + +A disclosure component knows nothing about the list. It wraps its state change: + +```tsx +const hold = useDisclosureHold(); +const handleToggle = () => hold(() => setExpanded((value) => !value)); +``` + +`view.tsx` wraps every timeline row in `DisclosureRow`, which routes `hold` to the strategy's +`holdRow(rowId, apply)` (`packages/app/src/agent-stream/disclosure.tsx`). Outside a timeline row, +`apply` runs as is. The strategy owns when `apply` runs and how the row keeps its place. + +Following output: + +- A hold always detaches the bottom anchor controller (`holdForDisclosure`). +- When the hold ends, the controller re-sticks only if the list sits at its end and no user scroll + owns the viewport (`releaseDisclosure({ isAtEnd })`). Expanding at the bottom therefore leaves + the list detached with the scroll-to-bottom pill showing; collapsing back to the end resumes + following. +- Scroll events during the hold never re-stick: the correction is a programmatic scroll that + would otherwise read as "back at the bottom". + +The trigger can only stay put when the list has scroll room for the change. When the loaded +content fits the viewport, or a collapse at the end would need an offset past the end, the list +ends at its end and the trigger moves by the difference. + +## Web + +`holdRow` pins the row in the reading anchor and runs `apply`; the next reading reconcile restores +the row's top after layout, before paint (`strategy-web.tsx`). + +## Native + +On compact layouts, tool calls and tool-call groups open a sheet instead of expanding, so they do +not hold. Summary lines and todo cards expand inline. + +The timeline is a LegendList in forward order: one `data` array of history rows followed by the +live head, oldest first (`strategy-native.tsx`). LegendList keeps a row in place across a size +change in the commit that makes it, so a hold needs no anchor neighbour, native child index or +render window stretch. The inverted FlatList it replaced needed all three, and three commits per +toggle; its applied commit took up to 3.5 s of JS on a long turn. + +### Who owns the offset + +One owner keeps the scroll offset at any moment: + +| State | Owner | +| ---------------------- | ----------------------------------------------------------------------------------------- | +| Before rows are placed | LegendList's initial scroll to the end. It keeps the rows hidden until they are measured. | +| Following the end | The end pin (`native-end-pin.tsx`). | +| Disclosure hold | LegendList's `maintainVisibleContentPosition`, restoring only the held row. | +| User scroll | The finger. The controller detaches at drag start. | +| Detached | LegendList's `maintainVisibleContentPosition` on the first visible row. | +| Keyboard motion | Nobody. The keyboard translates the list with the composer and leaves the offset alone. | + +The end pin is a zero-height view at the bottom of the content and the content's first native +child, with React Native's `maintainVisibleContentPosition` set to `{ minIndexForVisible: 0 }`. The +native correction runs on the UI thread around each mount, so a row that grows or lands at the end +scrolls in the frame it lays out. The controller scrolls only for a viewport resize (the composer +growing or shrinking) and an explicit jump, both through the native `scrollToEnd`. + +Do not use LegendList's `maintainScrollAtEnd` instead. It scrolls from JS after the mount to the end +its JS sizes predict: growth paints below the viewport for a frame, and the scroll lands short of +the native end, which the controller reads as the user scrolling away. + +The keyboard never changes the list's size, padding or offset. `KeyboardTranslateView` moves the list +and the composer together on the UI thread, and the composer viewport clips the top of the list +while the keyboard is open. Do not add a keyboard-sized inset to keep that content reachable: it can +only commit after the motion, and while detached or holding nothing corrects for it, so every row +jumps by the keyboard height after the keyboard settles. + +### The hold + +`holdRow(rowId, apply)` runs in the toggle's event: + +1. Mark the row held and detach the controller (`holdForDisclosure`). The pin goes away and + LegendList's `maintainVisibleContentPosition` takes over, restoring only the held row + (`shouldRestorePosition`). Upstream LegendList anchors only rows from the first fully visible + one down, and a size change with no data change keeps the first row in view whatever + `shouldRestorePosition` says. A row cut off at the top would then let the row below it hold + instead, moving the trigger by the row's size change. The LegendList patch counts rows cut off + at the top as in view and applies `shouldRestorePosition` to size changes too. +2. Run `apply`. +3. Call the row's `useSyncLayout` trigger. The toggle changes the row from its own state, so + LegendList's container does not re-render and would learn the new height from `onLayout` a task + later, after the change painted. The trigger puts the measurement and the corrected positions + in the toggle's own commit. + +The hold ends two frames after the last item size change, since the list measures the toggled row +and any rows it inserted over several frames (`native-disclosure-hold.ts`). The controller then +re-sticks only if the list is within 1 px of its end, the same distance web resumes from. + +### User scroll + +The controller detaches at `onScrollBeginDrag`, so lines that stream in during a drag grow below +the rows under the finger. The detach reaches native a JS round trip after the drag begins, so for +those frames the pin is still mounted. The scroll view subclasses skip the pin's correction while +the user drags (see [scroll view corrections](#scroll-view-corrections)). They skip only the pin's: +in the detached state LegendList uses the same native correction, and a history page that lands +mid-drag needs it. They key on the drag, not the touch, so a tap or long press while following +keeps following. + +Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts +or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that +land during the bounce must keep scrolling in. A release anywhere short of the end stays detached +with the scroll-to-bottom pill, so the rows the user let go of never move. + +### Draw distance + +`drawDistance` is 2000 px on each side of the viewport. A fling over rows that were never measured +blanks the viewport until LegendList renders and measures them. Twelve flings over a chat with a +15,000 px turn expanded, Android emulator, dev build: + +| List | Blank viewport | Janky frames | p50 / p90 / p99 | +| ----------------------------- | -------------------------- | ------------ | ---------------- | +| Inverted FlatList | 1.3 to 1.7 s over 5 flings | 10.8% | 27 / 48 / 109 ms | +| LegendList, drawDistance 500 | 1.0 to 1.5 s per fling | | | +| LegendList, drawDistance 2000 | none | 1.7% | 24 / 32 / 48 ms | + +With every turn collapsed, flinging from a cold open into history did not blank at any value from +500 to 2000, and jank followed host load (19 to 40 on 16 cores) rather than draw distance. Measure +1000 and 1500 on the expanded chat, on a host under load 10, before lowering it. + +### LegendList patch + +`patches/@legendapp+list+3.7.1.patch` carries ten changes for the timeline. Its header lists +them by purpose, and +[legend-list #593](https://github.com/LegendApp/legend-list/issues/593) reports each upstream. +Drop a hunk when the installed LegendList ships its fix. + +### Scroll view corrections + +`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass: +Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`. +`VisibleContentPositionClampTest` covers Android; iOS has no native test harness. + +**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set +applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during +the mount, and React Native then adds the anchor's movement to the clamped offset +([RN #58578](https://github.com/react/react-native/issues/58578), fix in +[PR #58695](https://github.com/react/react-native/pull/58695)). A turn that folds while the list +follows its end lands above the end. Android defers the clamp to the pre-draw pass, and iOS restores +the pre-mount offset for the adjustment and clamps after it. + +**Removed anchor (iOS).** A hold removes the end pin in the same mount that opens the tapped +disclosure, and React Native 0.86 on iOS moves the offset by the removed view's frame. The iOS +subclass keeps the offset. Android keeps a removed view's last frame, so it keeps the offset already. +React Native 0.88 skips the adjustment for a removed anchor +([PR #57294](https://github.com/react/react-native/pull/57294)). + +**End pin during a drag.** Both subclasses recognize the pin by its native ID, `paseo-end-pin`, +and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React +Native counterpart; it stays while the timeline uses the pin. + +Remove the content clamp and removed-anchor overrides once the installed React Native includes +their fixes. diff --git a/docs/agent-stream-performance.md b/docs/agent-stream-performance.md index ec506ee99b..fb022895bc 100644 --- a/docs/agent-stream-performance.md +++ b/docs/agent-stream-performance.md @@ -34,10 +34,10 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste - **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each. - **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete. - **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way. -- **A history row re-renders only when its item or layout item identity changes.** The inverted - FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a - memo boundary each coalesced tick re-rendered every mounted row (about 50 on a phone, 100–250 ms of - JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed, +- **A history row re-renders only when its item or layout item identity changes.** Every + coalesced tick hands the list a new layout. Without a memo boundary each tick re-rendered every + mounted row (measured on the inverted FlatList native used before LegendList: about 50 rows on a + phone, 100–250 ms of JS per tick). `layoutStream` keeps a layout item's identity when nothing about it changed, `useRevisedHistoryRows` hands a fresh item identity to rows whose tool-call group, expanded state, or breakpoint changed, and `StreamRow` memoizes on both. Every viewport runs its history through that hook; the web viewport once skipped it and history hosts of a live tool group went diff --git a/docs/android.md b/docs/android.md index 6b10a56892..8b6201ae91 100644 --- a/docs/android.md +++ b/docs/android.md @@ -174,18 +174,18 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \ This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:` works directly; on Android you need `10.0.2.2` or `adb reverse`. -## Inverted timeline input +## Scroll view corrections -Android focus and selection visibility requests must not reposition inverted timelines. The -`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for -child-reveal scroll calculations when the view's transform matrix inverts the vertical axis. React -Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotation with positive -`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll -commands still work; non-inverted scroll views keep Android's default behavior. +`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which +keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the +chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) +lists the corrections and the React Native issues they work around. Its instrumentation test, +`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager: -Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical -wheel axis for inverted scroll views before delegating to Android, preserving native scroll -bounds and React Native's scroll events and disabled-scroll behavior. +```bash +cd packages/app/android +./gradlew :paseo-scroll:connectedDebugAndroidTest --no-daemon --max-workers=2 -Dorg.gradle.parallel=false +``` Register this package before React Native's core package through its Expo config plugin. Normal Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does diff --git a/docs/data-model.md b/docs/data-model.md index 296720fe08..518d3e03d1 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -26,6 +26,10 @@ checkout root. They intentionally differ for an exact subproject inside a worktr restore, branch auto-name, and descriptor flows consume those persisted facts rather than rediscovering ownership from a directory that may already be gone. Reconciliation may refresh mutable placement facts, but never changes `projectId`, `cwd`, `displayName`, or `baseBranch`. +An explicit base selection is stored in the checkout's Git-directory Paseo metadata and +mirrored to `baseBranch` in every workspace record sharing that checkout. The record keeps +recovery possible after the checkout metadata is deleted. A newly opened workspace inherits +the checkout's selected base; separate worktrees keep separate bases. Workspace archive runs lifecycle teardown from the exact `cwd` but removes only the backing `worktreeRoot` after its last active reference disappears. Worktree recovery recreates that backing checkout from `mainRepoRoot`, then restores the relative path from `worktreeRoot` to `cwd`. @@ -471,7 +475,7 @@ Array of workspace records. A workspace is a specific working directory within a | `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | | `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | | `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | -| `baseBranch` | `string \| null` | Comparison base retained across archive and restore. Branch-off creation stores the resolved ref; legacy and PR-checkout records hold a bare name. Null means no recorded base. | +| `baseBranch` | `string \| null` | Current base retained across archive and restore. Explicit selections and branch-off creation store exact refs; older records may store branch names. Null means no recorded base. | | `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | | `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | | `createdAt` | `string` (ISO 8601) | | diff --git a/docs/design.md b/docs/design.md index 8a2cae25ba..e840e81bfa 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,9 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als `destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog. -Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. +Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. -Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use. +One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size. + +Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use. A `` wrapping a `` is a sixth variant. It is wrong. ` + ) : null} + + ), + [t, isPending, error, isError, handleRetry], + ); + + if (!supported || !isWorktree || !workspaceId) return {label}; + + return ( + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + anchor: { flexShrink: 1, minWidth: 0 }, + footer: { padding: theme.spacing[3], gap: theme.spacing[2] }, + text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/hooks/pointer-capabilities.test.ts b/packages/app/src/hooks/pointer-capabilities.test.ts new file mode 100644 index 0000000000..167db7c553 --- /dev/null +++ b/packages/app/src/hooks/pointer-capabilities.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { createPointerCapabilitiesSource } from "./pointer-capabilities"; + +class FakeMediaQueryList { + private readonly listeners = new Set<() => void>(); + + constructor( + readonly media: string, + public matches: boolean, + ) {} + + addEventListener(_type: "change", listener: () => void) { + this.listeners.add(listener); + } + + removeEventListener(_type: "change", listener: () => void) { + this.listeners.delete(listener); + } + + setMatches(matches: boolean) { + this.matches = matches; + for (const listener of this.listeners) listener(); + } +} + +function createFakeDisplay(initial: { finePointer: boolean; touchScreen: boolean }) { + const queries = { + finePointer: new FakeMediaQueryList("(hover: hover) and (pointer: fine)", initial.finePointer), + touchScreen: new FakeMediaQueryList("(any-pointer: coarse)", initial.touchScreen), + }; + const matchMedia = (query: string) => { + const match = Object.values(queries).find((list) => list.media === query); + if (!match) throw new Error(`Unexpected media query ${query}`); + return match as unknown as MediaQueryList; + }; + return { queries, matchMedia }; +} + +describe("pointer capabilities", () => { + it("reports a hybrid touch laptop as both a fine pointer and a touch screen", () => { + const display = createFakeDisplay({ finePointer: true, touchScreen: true }); + const source = createPointerCapabilitiesSource(display.matchMedia); + + expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true }); + }); + + it("notifies and updates the touch answer when a touch screen appears or goes away", () => { + const display = createFakeDisplay({ finePointer: true, touchScreen: false }); + const source = createPointerCapabilitiesSource(display.matchMedia); + const seen: boolean[] = []; + const unsubscribe = source.subscribe(() => seen.push(source.getSnapshot().touchScreen)); + + display.queries.touchScreen.setMatches(true); + display.queries.touchScreen.setMatches(false); + unsubscribe(); + display.queries.touchScreen.setMatches(true); + + expect(seen).toEqual([true, false]); + }); + + it("keeps one snapshot until an answer changes", () => { + const display = createFakeDisplay({ finePointer: false, touchScreen: true }); + const source = createPointerCapabilitiesSource(display.matchMedia); + const first = source.getSnapshot(); + + expect(source.getSnapshot()).toBe(first); + display.queries.finePointer.setMatches(true); + expect(source.getSnapshot()).toEqual({ finePointer: true, touchScreen: true }); + }); + + it("reports neither capability without media queries", () => { + expect(createPointerCapabilitiesSource(null).getSnapshot()).toEqual({ + finePointer: false, + touchScreen: false, + }); + }); +}); diff --git a/packages/app/src/hooks/pointer-capabilities.ts b/packages/app/src/hooks/pointer-capabilities.ts new file mode 100644 index 0000000000..360ff88c24 --- /dev/null +++ b/packages/app/src/hooks/pointer-capabilities.ts @@ -0,0 +1,88 @@ +import { useSyncExternalStore } from "react"; +import { isWeb } from "@/constants/platform"; + +// A touch tablet running the desktop layout is still `isWeb === false || true` +// depending on the build, so neither platform gate answers "can this user +// hover and point precisely" or "can this user touch the screen". Only the +// media queries do, and the same queries notify when the answer changes +// (detachable keyboards, tablet mode, devtools touch emulation). +const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)"; +const TOUCH_SCREEN_QUERY = "(any-pointer: coarse)"; + +export interface PointerCapabilities { + /** A mouse-like pointer drives the UI. */ + finePointer: boolean; + /** A touch screen is available, possibly next to a mouse on hybrid devices. */ + touchScreen: boolean; +} + +type MatchMedia = (query: string) => MediaQueryList; + +export interface PointerCapabilitiesSource { + getSnapshot: () => PointerCapabilities; + subscribe: (onChange: () => void) => () => void; +} + +export function createPointerCapabilitiesSource( + matchMedia: MatchMedia | null, +): PointerCapabilitiesSource { + const finePointerQuery = matchMedia?.(FINE_POINTER_QUERY) ?? null; + const touchScreenQuery = matchMedia?.(TOUCH_SCREEN_QUERY) ?? null; + let snapshot: PointerCapabilities = { finePointer: false, touchScreen: false }; + + return { + getSnapshot() { + const finePointer = finePointerQuery?.matches ?? false; + const touchScreen = touchScreenQuery?.matches ?? false; + if (snapshot.finePointer !== finePointer || snapshot.touchScreen !== touchScreen) { + snapshot = { finePointer, touchScreen }; + } + return snapshot; + }, + subscribe(onChange) { + finePointerQuery?.addEventListener("change", onChange); + touchScreenQuery?.addEventListener("change", onChange); + return () => { + finePointerQuery?.removeEventListener("change", onChange); + touchScreenQuery?.removeEventListener("change", onChange); + }; + }, + }; +} + +function browserMatchMedia(): MatchMedia | null { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return null; + return (query) => window.matchMedia(query); +} + +// Native builds run on iOS and Android touch screens. A paired mouse or pen is +// filtered per gesture event by its pointer type, not by this capability. +const NATIVE_POINTER_CAPABILITIES: PointerCapabilities = { finePointer: false, touchScreen: true }; +const nativeSource: PointerCapabilitiesSource = { + getSnapshot: () => NATIVE_POINTER_CAPABILITIES, + subscribe: () => () => {}, +}; + +const source = isWeb ? createPointerCapabilitiesSource(browserMatchMedia()) : nativeSource; +const SERVER_SNAPSHOT: PointerCapabilities = { finePointer: false, touchScreen: false }; + +function getServerSnapshot(): PointerCapabilities { + return SERVER_SNAPSHOT; +} + +export function usePointerCapabilities(): PointerCapabilities { + return useSyncExternalStore(source.subscribe, source.getSnapshot, getServerSnapshot); +} + +/** True when a mouse-like pointer drives the UI, read at call time. */ +export function hasFinePointer(): boolean { + return source.getSnapshot().finePointer; +} + +/** Touch sizing remains enabled on hybrid browsers that report touch points. */ +export function hasTouchScreen(): boolean { + return ( + source.getSnapshot().touchScreen || + (isWeb && typeof window !== "undefined" && window.navigator.maxTouchPoints > 0) + ); +} diff --git a/packages/app/src/hooks/use-fine-pointer.ts b/packages/app/src/hooks/use-fine-pointer.ts deleted file mode 100644 index a0bc2489bf..0000000000 --- a/packages/app/src/hooks/use-fine-pointer.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useState } from "react"; -import { isWeb } from "@/constants/platform"; - -// A touch tablet running the desktop layout is still `isWeb === false || true` -// depending on the build, so neither platform gate answers "can this user -// hover and point precisely". Only the media query does. -const FINE_POINTER_QUERY = "(hover: hover) and (pointer: fine)"; - -function getFinePointerQueryList(): MediaQueryList | null { - if (!isWeb || typeof window === "undefined" || typeof window.matchMedia !== "function") { - return null; - } - return window.matchMedia(FINE_POINTER_QUERY); -} - -/** True when a mouse-like pointer drives the UI. Always false on native. */ -export function hasFinePointer(): boolean { - return getFinePointerQueryList()?.matches ?? false; -} - -/** - * Reactive `hasFinePointer()` — re-renders when the user switches between a - * mouse and touch (detachable keyboards, tablet mode). - */ -export function useHasFinePointer(): boolean { - const [finePointer, setFinePointer] = useState(hasFinePointer); - - useEffect(() => { - const mediaQuery = getFinePointerQueryList(); - if (!mediaQuery) return; - const handleChange = () => setFinePointer(mediaQuery.matches); - handleChange(); - mediaQuery.addEventListener?.("change", handleChange); - return () => { - mediaQuery.removeEventListener?.("change", handleChange); - }; - }, []); - - return finePointer; -} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a0bf83f522..dbc01ed33c 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -935,6 +935,9 @@ export const ar: TranslationResources = { }, }, diff: { + baseBranch: { + title: "الفرع الأساسي", + }, openChangesTab: "فتح علامة تبويب التغييرات", openDiffTab: "فتح علامة تبويب الفرق", closeChangesTab: "إغلاق علامة تبويب التغييرات", @@ -1238,6 +1241,7 @@ export const ar: TranslationResources = { hosts: "المضيفون", settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", + moreActions: "المزيد من الإجراءات", }, footer: { usage: "الاستخدام", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 4dc55675cc..9f52044f44 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -943,6 +943,9 @@ export const en = { }, }, diff: { + baseBranch: { + title: "Change base", + }, openChangesTab: "Open Changes tab", openDiffTab: "Open Diff tab", closeChangesTab: "Close Changes tab", @@ -1246,6 +1249,7 @@ export const en = { hosts: "Hosts", settings: "Settings", closeSidebar: "Close sidebar", + moreActions: "More actions", }, footer: { usage: "Usage", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 50ac39a097..f0b1a933e1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -968,6 +968,9 @@ export const es: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Rama base", + }, openChangesTab: "Abrir la pestaña Cambios", openDiffTab: "Abrir la pestaña Diferencia", closeChangesTab: "Cerrar la pestaña Cambios", @@ -1274,6 +1277,7 @@ export const es: TranslationResources = { hosts: "Hosts", settings: "Ajustes", closeSidebar: "Cerrar barra lateral", + moreActions: "Más acciones", }, footer: { usage: "Uso", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 01d9d23c8c..c34ec05e9d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -961,6 +961,9 @@ export const fr: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branche de base", + }, openChangesTab: "Ouvrir l’onglet Modifications", openDiffTab: "Ouvrir l’onglet Diff", closeChangesTab: "Fermer l’onglet Modifications", @@ -1268,6 +1271,7 @@ export const fr: TranslationResources = { hosts: "Hôtes", settings: "Paramètres", closeSidebar: "Fermer la barre latérale", + moreActions: "Plus d’actions", }, footer: { usage: "Utilisation", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 8b871580af..57a98133f7 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -948,6 +948,9 @@ export const ja: TranslationResources = { }, }, diff: { + baseBranch: { + title: "ベースブランチ", + }, openChangesTab: "変更タブを開く", openDiffTab: "差分タブを開く", closeChangesTab: "変更タブを閉じる", @@ -1252,6 +1255,7 @@ export const ja: TranslationResources = { hosts: "ホスト", settings: "設定", closeSidebar: "サイドバーを閉じる", + moreActions: "その他のアクション", }, footer: { usage: "使用状況", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 2a150157c7..5d7bcefed5 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -942,6 +942,9 @@ export const ko: TranslationResources = { }, }, diff: { + baseBranch: { + title: "기준 브랜치", + }, openChangesTab: "변경사항 탭 열기", openDiffTab: "Diff 탭 열기", closeChangesTab: "변경사항 탭 닫기", @@ -1245,6 +1248,7 @@ export const ko: TranslationResources = { hosts: "호스트", settings: "설정", closeSidebar: "사이드바 닫기", + moreActions: "추가 작업", }, footer: { usage: "사용량", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2f93e3a436..727440e6b0 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -958,6 +958,9 @@ export const ptBR: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branch base", + }, openChangesTab: "Abrir a aba Alterações", openDiffTab: "Abrir a aba Diff", closeChangesTab: "Fechar a aba Alterações", @@ -1264,6 +1267,7 @@ export const ptBR: TranslationResources = { hosts: "Hosts", settings: "Configurações", closeSidebar: "Fechar barra lateral", + moreActions: "Mais ações", }, footer: { usage: "Uso", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 615e365d3a..b966f408fc 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -952,6 +952,9 @@ export const ru: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Базовая ветка", + }, openChangesTab: "Открыть вкладку «Изменения»", openDiffTab: "Открыть вкладку «Дифф»", closeChangesTab: "Закрыть вкладку «Изменения»", @@ -1256,6 +1259,7 @@ export const ru: TranslationResources = { hosts: "Хосты", settings: "Настройки", closeSidebar: "Закрыть боковую панель", + moreActions: "Дополнительные действия", }, footer: { usage: "Использование", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 4046b19e2e..3b6a589038 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -927,6 +927,9 @@ export const zhCN: TranslationResources = { }, }, diff: { + baseBranch: { + title: "基准分支", + }, openChangesTab: "打开“更改”标签页", openDiffTab: "打开 Diff 标签页", closeChangesTab: "关闭“更改”标签页", @@ -1230,6 +1233,7 @@ export const zhCN: TranslationResources = { hosts: "Hosts", settings: "设置", closeSidebar: "关闭侧边栏", + moreActions: "更多操作", }, footer: { usage: "使用情况", diff --git a/packages/app/src/keyboard/shift/index.ts b/packages/app/src/keyboard/shift/index.ts index e54fc7ace9..e570bd3fee 100644 --- a/packages/app/src/keyboard/shift/index.ts +++ b/packages/app/src/keyboard/shift/index.ts @@ -1,5 +1,4 @@ export { KeyboardShiftProvider, useKeyboardShiftStyle } from "./internal/provider"; -export { useKeyboardShift, useSettledKeyboardShift } from "./internal/context"; +export { useKeyboardShift } from "./internal/context"; export { KeyboardTranslateView } from "./internal/translate"; export { useKeyboardAnchor } from "./internal/anchor"; -export { useKeyboardStreamInset } from "./internal/stream"; diff --git a/packages/app/src/keyboard/shift/internal/context.ts b/packages/app/src/keyboard/shift/internal/context.ts index f010c01255..b86005e214 100644 --- a/packages/app/src/keyboard/shift/internal/context.ts +++ b/packages/app/src/keyboard/shift/internal/context.ts @@ -17,6 +17,7 @@ export const KeyboardShiftContext = createContext({ - translation: { enabled: false, bottomClearance: 0 }, + translation: { enabled: false, bottomClearance: 0, keyboardGap: 0 }, measureAtRest: measureInWindow, }); -export const SettledKeyboardShiftContext = createContext(null); /** Read the app-wide keyboard inset without loading its native provider implementation. */ export function useKeyboardShift(): KeyboardShiftContextValue { @@ -51,12 +51,3 @@ export function useKeyboardShift(): KeyboardShiftContextValue { } return context; } - -/** Read the keyboard inset published after native keyboard motion has settled. */ -export function useSettledKeyboardShift(): number { - const context = useContext(SettledKeyboardShiftContext); - if (context === null) { - throw new Error("useSettledKeyboardShift must be used inside KeyboardShiftProvider"); - } - return context; -} diff --git a/packages/app/src/keyboard/shift/internal/policy.test.ts b/packages/app/src/keyboard/shift/internal/policy.test.ts index 6867f3ffa1..6b278ed224 100644 --- a/packages/app/src/keyboard/shift/internal/policy.test.ts +++ b/packages/app/src/keyboard/shift/internal/policy.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - resolveStreamKeyboardInset, shouldReconcileHiddenKeyboardEnd, resolveKeyboardLift, resolveKeyboardShift, @@ -29,30 +28,22 @@ describe("keyboard layout reservation", () => { }); }); -describe("resolveStreamKeyboardInset", () => { - it("uses the native scroll inset on iOS without changing content size", () => { - expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: 311 })).toEqual({ - contentContainerPaddingBottom: 0, - contentInset: { bottom: 311 }, - }); - }); - - it("keeps Android's exact content-container padding behavior", () => { - expect(resolveStreamKeyboardInset({ platform: "android", settledShift: 311 })).toEqual({ - contentContainerPaddingBottom: 311, - contentInset: undefined, - }); - }); - - it("does not expose a negative inset", () => { - expect(resolveStreamKeyboardInset({ platform: "ios", settledShift: -1 })).toEqual({ - contentContainerPaddingBottom: 0, - contentInset: { bottom: 0 }, - }); - }); -}); - describe("resolveKeyboardShift", () => { + it.each([0, -1])( + "ignores a rotation report with height %s and progress one", + (rawKeyboardHeight) => { + expect( + resolveKeyboardShift({ + rawKeyboardHeight, + keyboardProgress: 1, + bottomInset: 24, + isIos: true, + iosMinHeight: 120, + }), + ).toBe(0); + }, + ); + it("keeps the existing open-keyboard offset behavior", () => { expect( resolveKeyboardShift({ @@ -92,16 +83,46 @@ describe("resolveKeyboardShift", () => { describe("resolveKeyboardLift", () => { it("lifts a docked view by the whole shift", () => { - expect(resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0 })).toBe(302); + expect( + resolveKeyboardLift({ shift: 302, enabled: true, bottomClearance: 0, keyboardGap: 0 }), + ).toBe(302); }); it("lets the keyboard fill a centered form's clearance before lifting it", () => { - expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267 })).toBe(136); - expect(resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455 })).toBe(0); + expect( + resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 267, keyboardGap: 0 }), + ).toBe(136); + expect( + resolveKeyboardLift({ shift: 403, enabled: true, bottomClearance: 455, keyboardGap: 0 }), + ).toBe(0); + }); + + it("rises a docked composer's gap beyond the keyboard overlap, never at rest", () => { + const docked = { enabled: true, bottomClearance: 0, keyboardGap: 16 }; + expect(resolveKeyboardLift({ ...docked, shift: 0 })).toBe(0); + expect(resolveKeyboardLift({ ...docked, shift: 8 })).toBe(16); + expect(resolveKeyboardLift({ ...docked, shift: 276 })).toBe(292); + }); + + it("measures the shift from the window's bottom clearance, not the rectangular inset", () => { + // iPhone Duo outer screen: the bar guide rests 24pt up where safeAreaInsets reports 34. + const shift = resolveKeyboardShift({ + rawKeyboardHeight: 300, + keyboardProgress: 1, + bottomInset: 24, + isIos: true, + iosMinHeight: 120, + }); + expect(shift).toBe(276); + const lift = resolveKeyboardLift({ shift, enabled: true, bottomClearance: 0, keyboardGap: 16 }); + const restingComposerBottom = 24; + expect(restingComposerBottom + lift - 300).toBe(16); }); it("keeps a disabled view in place", () => { - expect(resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0 })).toBe(0); + expect( + resolveKeyboardLift({ shift: 302, enabled: false, bottomClearance: 0, keyboardGap: 0 }), + ).toBe(0); }); }); diff --git a/packages/app/src/keyboard/shift/internal/policy.ts b/packages/app/src/keyboard/shift/internal/policy.ts index 4e618c8a49..3cd290fd94 100644 --- a/packages/app/src/keyboard/shift/internal/policy.ts +++ b/packages/app/src/keyboard/shift/internal/policy.ts @@ -10,26 +10,6 @@ export function reserveKeyboardLayoutShift(input: { return input.phase === "start" ? Math.max(input.current, input.target) : input.target; } -export function resolveStreamKeyboardInset(input: { - platform: "android" | "ios"; - settledShift: number; -}): { - contentContainerPaddingBottom: number; - contentInset: { bottom: number } | undefined; -} { - const settledShift = Math.max(0, input.settledShift); - if (input.platform === "ios") { - return { - contentContainerPaddingBottom: 0, - contentInset: { bottom: settledShift }, - }; - } - return { - contentContainerPaddingBottom: settledShift, - contentInset: undefined, - }; -} - export function resolveKeyboardShift(input: { rawKeyboardHeight: number; keyboardProgress: number; @@ -57,9 +37,14 @@ export function resolveKeyboardLift(input: { shift: number; enabled: boolean; bottomClearance: number; + keyboardGap: number; }): number { "worklet"; - return input.enabled ? Math.max(0, input.shift - input.bottomClearance) : 0; + if (!input.enabled) return 0; + // The gap opens over the shift's first points, so the view never rises at rest. + return ( + Math.max(0, input.shift - input.bottomClearance) + Math.min(input.keyboardGap, input.shift) + ); } export function shouldReconcileHiddenKeyboardEnd(input: { diff --git a/packages/app/src/keyboard/shift/internal/provider.ts b/packages/app/src/keyboard/shift/internal/provider.ts index 7b2ff46eae..a99a148aef 100644 --- a/packages/app/src/keyboard/shift/internal/provider.ts +++ b/packages/app/src/keyboard/shift/internal/provider.ts @@ -1,4 +1,4 @@ -import { createElement, useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { createElement, useEffect, useMemo, type ReactNode } from "react"; import { Platform } from "react-native"; import type { ViewStyle } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -8,19 +8,17 @@ import { } from "react-native-keyboard-controller"; import { useAnimatedStyle, - useAnimatedReaction, useDerivedValue, useSharedValue, type SharedValue, } from "react-native-reanimated"; -import { scheduleOnRN } from "react-native-worklets"; import { DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT, resolveKeyboardShift, reserveKeyboardLayoutShift, shouldReconcileHiddenKeyboardEnd, } from "./policy"; -import { KeyboardShiftContext, SettledKeyboardShiftContext, useKeyboardShift } from "./context"; +import { KeyboardShiftContext, useKeyboardShift } from "./context"; type KeyboardShiftMode = "translate" | "padding"; @@ -31,11 +29,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { const isIos = Platform.OS === "ios"; const isMoving = useSharedValue(false); const layoutShift = useSharedValue(0); - const [settledShift, setSettledShift] = useState(0); - const publishSettledShift = useCallback((nextShift: number) => { - setSettledShift((currentShift) => (currentShift === nextShift ? currentShift : nextShift)); - }, []); - useEffect(() => { bottomInset.value = insets.bottom; }, [bottomInset, insets.bottom]); @@ -93,16 +86,6 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { [bottomInset, isIos, isMoving, keyboardHeight, keyboardProgress, layoutShift], ); - useAnimatedReaction( - () => ({ moving: isMoving.value, shift: shift.value }), - (current, previous) => { - if (!current.moving && (previous === null || previous.moving)) { - scheduleOnRN(publishSettledShift, current.shift); - } - }, - [isMoving, publishSettledShift, shift], - ); - const value = useMemo( () => ({ shift, @@ -113,11 +96,7 @@ export function KeyboardShiftProvider({ children }: { children: ReactNode }) { [bottomInset, isMoving, layoutShift, shift], ); - return createElement( - KeyboardShiftContext.Provider, - { value }, - createElement(SettledKeyboardShiftContext.Provider, { value: settledShift }, children), - ); + return createElement(KeyboardShiftContext.Provider, { value }, children); } export function useKeyboardShiftStyle(input: { mode: KeyboardShiftMode; enabled?: boolean }): { diff --git a/packages/app/src/keyboard/shift/internal/stream.ts b/packages/app/src/keyboard/shift/internal/stream.ts deleted file mode 100644 index 45fe167fe4..0000000000 --- a/packages/app/src/keyboard/shift/internal/stream.ts +++ /dev/null @@ -1,17 +0,0 @@ -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], - ); -} diff --git a/packages/app/src/keyboard/shift/internal/translate.android.tsx b/packages/app/src/keyboard/shift/internal/translate.android.tsx index cec2f0fcfd..5766c001ac 100644 --- a/packages/app/src/keyboard/shift/internal/translate.android.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.android.tsx @@ -10,18 +10,27 @@ interface KeyboardTranslateViewProps extends ViewProps { enabled?: boolean; /** Space below the view's resting position that the keyboard may fill before the view moves. */ bottomClearance?: number; + /** + * Distance the view rises beyond the keyboard's overlap of the safe area, so content + * resting on the safe-area boundary keeps this gap above the open keyboard. + */ + keyboardGap?: number; } export function KeyboardTranslateView({ children, enabled = true, bottomClearance = 0, + keyboardGap = 0, style, ...props }: KeyboardTranslateViewProps) { const { shift } = useKeyboardShift(); const frameRef = useRef(null); - const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]); + const translation = useMemo( + () => ({ enabled, bottomClearance, keyboardGap }), + [bottomClearance, enabled, keyboardGap], + ); const scope = useKeyboardTranslationScope(frameRef, translation); const keyboardStyle = useAnimatedStyle(() => ({ transform: [{ translateY: -resolveKeyboardLift({ shift: shift.value, ...translation }) }], diff --git a/packages/app/src/keyboard/shift/internal/translate.ios.tsx b/packages/app/src/keyboard/shift/internal/translate.ios.tsx index 8628ba8b8c..5b5f378e0d 100644 --- a/packages/app/src/keyboard/shift/internal/translate.ios.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.ios.tsx @@ -10,30 +10,40 @@ interface KeyboardTranslateViewProps extends ViewProps { enabled?: boolean; /** Space below the view's resting position that the keyboard may fill before the view moves. */ bottomClearance?: number; + /** + * Distance the view rises beyond the keyboard's overlap of the safe area, so content + * resting on the safe-area boundary keeps this gap above the open keyboard. + */ + keyboardGap?: number; } export function KeyboardTranslateView({ children, enabled = true, bottomClearance = 0, + keyboardGap = 0, style, ...props }: KeyboardTranslateViewProps) { const insets = useSafeAreaInsets(); const { height, progress } = useKeyboardAnimation(); - const translateY = useMemo( - () => - Animated.add( - Animated.add(height, Animated.multiply(progress, insets.bottom)), - bottomClearance, - ).interpolate({ - inputRange: [-1, 0], - outputRange: [-1, 0], - extrapolateLeft: "extend", - extrapolateRight: "clamp", - }), - [bottomClearance, height, insets.bottom, progress], - ); + // Mirrors resolveKeyboardLift on the native driver: `overlap` is the negated shift. + const translateY = useMemo(() => { + const overlap = Animated.add(height, Animated.multiply(progress, insets.bottom)); + const lift = Animated.add(overlap, bottomClearance).interpolate({ + inputRange: [-1, 0], + outputRange: [-1, 0], + extrapolateLeft: "extend", + extrapolateRight: "clamp", + }); + if (!(keyboardGap > 0)) return lift; + const gap = overlap.interpolate({ + inputRange: [-keyboardGap, 0], + outputRange: [-keyboardGap, 0], + extrapolate: "clamp", + }); + return Animated.add(lift, gap); + }, [bottomClearance, height, insets.bottom, keyboardGap, progress]); // Fullscreen resizes this view while disabling translation. iOS Fabric can // reapply its previous transform during that layout commit. Keep the graph // attached and apply the mode on the native animation frame; a static zero or @@ -57,7 +67,10 @@ export function KeyboardTranslateView({ ); const frameRef = useRef(null); - const translation = useMemo(() => ({ enabled, bottomClearance }), [bottomClearance, enabled]); + const translation = useMemo( + () => ({ enabled, bottomClearance, keyboardGap }), + [bottomClearance, enabled, keyboardGap], + ); const scope = useKeyboardTranslationScope(frameRef, translation); return ( diff --git a/packages/app/src/keyboard/shift/internal/translate.tsx b/packages/app/src/keyboard/shift/internal/translate.tsx index 6a0506c301..b04613d495 100644 --- a/packages/app/src/keyboard/shift/internal/translate.tsx +++ b/packages/app/src/keyboard/shift/internal/translate.tsx @@ -5,12 +5,14 @@ interface KeyboardTranslateViewProps extends ViewProps { children: ReactNode; enabled?: boolean; bottomClearance?: number; + keyboardGap?: number; } export function KeyboardTranslateView({ children, enabled: _enabled, bottomClearance: _bottomClearance, + keyboardGap: _keyboardGap, ...props }: KeyboardTranslateViewProps) { return {children}; diff --git a/packages/app/src/mobile-panels/gesture-intent.test.ts b/packages/app/src/mobile-panels/gesture-intent.test.ts index 74035096fb..a85c25e81b 100644 --- a/packages/app/src/mobile-panels/gesture-intent.test.ts +++ b/packages/app/src/mobile-panels/gesture-intent.test.ts @@ -3,19 +3,36 @@ import { describe, expect, it } from "vitest"; import { resolveMobilePanelGestureIntent } from "./gesture-intent"; describe("mobile panel gesture intent", () => { + it("rejects a pending swipe when presentation changes before activation", () => { + expect( + resolveMobilePanelGestureIntent({ + enabled: false, + deltaX: 40, + deltaY: 2, + direction: 1, + openGesturesBlocked: false, + openEdge: null, + }), + ).toBe("fail"); + }); + it("blocks both panel-opening directions while the active surface owns horizontal dragging", () => { expect([ resolveMobilePanelGestureIntent({ + enabled: true, deltaX: 40, deltaY: 2, direction: 1, openGesturesBlocked: true, + openEdge: null, }), resolveMobilePanelGestureIntent({ + enabled: true, deltaX: -40, deltaY: 2, direction: -1, openGesturesBlocked: true, + openEdge: null, }), ]).toEqual(["fail", "fail"]); }); @@ -23,17 +40,68 @@ describe("mobile panel gesture intent", () => { it("keeps ordinary horizontal panel gestures available when unblocked", () => { expect([ resolveMobilePanelGestureIntent({ + enabled: true, deltaX: 40, deltaY: 2, direction: 1, openGesturesBlocked: false, + openEdge: null, }), resolveMobilePanelGestureIntent({ + enabled: true, deltaX: -40, deltaY: 2, direction: -1, openGesturesBlocked: false, + openEdge: null, }), ]).toEqual(["activate", "activate"]); }); + + it("opens phone web drawers only from the edge they slide in from", () => { + const swipe = { deltaY: 2, openGesturesBlocked: false }; + expect([ + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: 40, + direction: 1, + openEdge: { startX: 200, windowWidth: 400 }, + }), + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: 40, + direction: 1, + openEdge: { startX: 12, windowWidth: 400 }, + }), + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: -40, + direction: -1, + openEdge: { startX: 200, windowWidth: 400 }, + }), + resolveMobilePanelGestureIntent({ + enabled: true, + ...swipe, + deltaX: -40, + direction: -1, + openEdge: { startX: 388, windowWidth: 400 }, + }), + ]).toEqual(["fail", "activate", "fail", "activate"]); + }); + + it("opens from anywhere when the layout does not require an edge start", () => { + expect( + resolveMobilePanelGestureIntent({ + enabled: true, + deltaX: 40, + deltaY: 2, + direction: 1, + openGesturesBlocked: false, + openEdge: null, + }), + ).toBe("activate"); + }); }); diff --git a/packages/app/src/mobile-panels/gesture-intent.ts b/packages/app/src/mobile-panels/gesture-intent.ts index 3399670924..6722ec0c0f 100644 --- a/packages/app/src/mobile-panels/gesture-intent.ts +++ b/packages/app/src/mobile-panels/gesture-intent.ts @@ -1,14 +1,39 @@ export type MobilePanelGestureDirection = -1 | 1; export type MobilePanelGestureIntent = "activate" | "fail" | "wait"; +/** How far from the window edge an edge-only open gesture may start. */ +export const MOBILE_PANEL_OPEN_EDGE_WIDTH = 32; + +/** + * Where an edge-only open gesture started. Phone web opens drawers only from the edge they slide in + * from: interior horizontal scrollers (terminals, code blocks, diffs) keep their swipes there. + */ +export interface MobilePanelOpenEdge { + startX: number; + windowWidth: number; +} + +function startsAtOpenEdge(edge: MobilePanelOpenEdge, direction: MobilePanelGestureDirection) { + "worklet"; + return direction === 1 + ? edge.startX <= MOBILE_PANEL_OPEN_EDGE_WIDTH + : edge.startX >= edge.windowWidth - MOBILE_PANEL_OPEN_EDGE_WIDTH; +} + export function resolveMobilePanelGestureIntent(input: { + enabled: boolean; deltaX: number; deltaY: number; direction: MobilePanelGestureDirection; openGesturesBlocked: boolean; + /** Required start edge for opening gestures, or `null` when they may start anywhere. */ + openEdge: MobilePanelOpenEdge | null; }): MobilePanelGestureIntent { "worklet"; - if (input.openGesturesBlocked) { + if (!input.enabled || input.openGesturesBlocked) { + return "fail"; + } + if (input.openEdge && !startsAtOpenEdge(input.openEdge, input.direction)) { return "fail"; } const directedDelta = input.deltaX * input.direction; diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index 2b3f3e11fe..c95c69aba4 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -1,16 +1,16 @@ import { createContext, useContext, useRef, type RefObject, useCallback, useMemo } from "react"; -import { Gesture, type GestureType } from "react-native-gesture-handler"; -import { useSharedValue } from "react-native-reanimated"; +import { Gesture, PointerType, type GestureType } from "react-native-gesture-handler"; +import { useDerivedValue, useSharedValue } from "react-native-reanimated"; import { scheduleOnRN } from "react-native-worklets"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context"; +import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; import { usePanelStore } from "@/stores/panel-store"; import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model"; import { useMobilePanelsRuntime } from "./provider"; import { resolveMobilePanelGestureIntent } from "./gesture-intent"; -const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32; - function isCurrentSelection(startedRevision: number): boolean { return usePanelStore.getState().mobilePanel.revision === startedRevision; } @@ -23,6 +23,15 @@ function useGestureState() { }; } +/** + * Gates a sidebar pan on the UI thread. The sidebar flips between the dock and the overlay at + * runtime, and on Android flipping the handler's `.enabled()` config from false back to true left + * it inert until the app reloaded (RNGH 2.32), so `.enabled()` only carries the device capability. + */ +function useGestureGate(enabled: boolean) { + return useDerivedValue(() => enabled, [enabled]); +} + function useRevisionCommit(action: () => void) { return useCallback( (revision: number) => { @@ -39,24 +48,35 @@ export function useOpenAgentListGesture(enabled: boolean) { beginGesture, finishGesture, leftOpenGestureRef, + leftPanelWidth: width, motionState, openGesturesBlocked, position, updateGesture, windowWidth, } = useMobilePanelsRuntime(); + const { touchScreen: hasTouchScreen } = usePointerCapabilities(); + // Phone web keeps interior swipes for the page's horizontal scrollers; the wide overlay opens + // from anywhere in the workspace. + const isCompact = useIsCompactFormFactor(); + const openFromEdgeOnly = isWeb && isCompact; const horizontalScroll = useHorizontalScrollOptional(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList); const commit = useRevisionCommit(showMobileAgentList); + const gate = useGestureGate(enabled); return useMemo( () => Gesture.Pan() .withRef(leftOpenGestureRef) - .enabled(enabled) + .enabled(hasTouchScreen) .manualActivation(true) - .onTouchesDown((event) => { + .onTouchesDown((event, stateManager) => { + if (!gate.value || event.pointerType !== PointerType.TOUCH) { + stateManager.fail(); + return; + } const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; @@ -76,15 +96,16 @@ export function useOpenAgentListGesture(enabled: boolean) { } const panIntent = resolveMobilePanelGestureIntent({ + enabled: gate.value, deltaX, deltaY, direction: 1, openGesturesBlocked: openGesturesBlocked.value, + openEdge: openFromEdgeOnly ? { startX: touchStartX.value, windowWidth } : null, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || horizontalScroll?.isAnyScrolledRight.value || - (isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) || panIntent === "fail" ) { stateManager.fail(); @@ -98,10 +119,10 @@ export function useOpenAgentListGesture(enabled: boolean) { startedRevision.value = beginGesture({ origin: "agent" }); }) .onUpdate((event) => { - updateGesture(startedRevision.value, -event.translationX / windowWidth); + updateGesture(startedRevision.value, -event.translationX / width); }) .onEnd((event, success) => { - const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500; + const shouldOpen = event.translationX > width / 3 || event.velocityX > 500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldOpen ? "agent-list" : "agent", @@ -113,43 +134,53 @@ export function useOpenAgentListGesture(enabled: boolean) { }), [ commit, - enabled, beginGesture, + gate, finishGesture, + hasTouchScreen, horizontalScroll?.isAnyScrolledRight, leftOpenGestureRef, motionState, + openFromEdgeOnly, openGesturesBlocked, position, startedRevision, touchStartX, touchStartY, updateGesture, + width, windowWidth, ], ); } -export function useCloseAgentListGesture() { +export function useCloseAgentListGesture(enabled = true) { const { beginGesture, finishGesture, leftCloseGestureRef, + leftPanelWidth: width, motionState, position, updateGesture, - windowWidth, } = useMobilePanelsRuntime(); + const { touchScreen: hasTouchScreen } = usePointerCapabilities(); const { startedRevision, touchStartX, touchStartY } = useGestureState(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const commit = useRevisionCommit(showMobileAgent); + const gate = useGestureGate(enabled); const gesture = useMemo( () => Gesture.Pan() .withRef(leftCloseGestureRef) + .enabled(hasTouchScreen) .manualActivation(true) - .onTouchesDown((event) => { + .onTouchesDown((event, stateManager) => { + if (!gate.value || event.pointerType !== PointerType.TOUCH) { + stateManager.fail(); + return; + } const touch = event.changedTouches[0]; if (touch) { touchStartX.value = touch.absoluteX; @@ -169,10 +200,12 @@ export function useCloseAgentListGesture() { } const panIntent = resolveMobilePanelGestureIntent({ + enabled: gate.value, deltaX, deltaY, direction: -1, openGesturesBlocked: false, + openEdge: null, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) || @@ -191,10 +224,10 @@ export function useCloseAgentListGesture() { }); }) .onUpdate((event) => { - updateGesture(startedRevision.value, -1 - event.translationX / windowWidth); + updateGesture(startedRevision.value, -1 - event.translationX / width); }) .onEnd((event, success) => { - const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500; + const shouldClose = event.translationX < -width / 3 || event.velocityX < -500; const result = finishGesture({ startedRevision: startedRevision.value, target: shouldClose ? "agent" : "agent-list", @@ -207,6 +240,8 @@ export function useCloseAgentListGesture() { [ beginGesture, commit, + gate, + hasTouchScreen, finishGesture, leftCloseGestureRef, motionState, @@ -215,7 +250,7 @@ export function useCloseAgentListGesture() { touchStartX, touchStartY, updateGesture, - windowWidth, + width, ], ); @@ -269,14 +304,15 @@ export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorer } const panIntent = resolveMobilePanelGestureIntent({ + enabled: enabled, deltaX, deltaY, direction: -1, openGesturesBlocked: openGesturesBlocked.value, + openEdge: isWeb ? { startX: touchStartX.value, windowWidth } : null, }); if ( !canBeginMobilePanelGesture(motionState.value, "agent", position.value) || - (isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) || panIntent === "fail" ) { stateManager.fail(); @@ -363,10 +399,12 @@ export function useCloseFileExplorerGesture(enabled = true) { } const panIntent = resolveMobilePanelGestureIntent({ + enabled: enabled, deltaX, deltaY, direction: 1, openGesturesBlocked: false, + openEdge: null, }); if ( !canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) || diff --git a/packages/app/src/mobile-panels/model.test.ts b/packages/app/src/mobile-panels/model.test.ts index 5e2d8e229c..ca4ef04a8a 100644 --- a/packages/app/src/mobile-panels/model.test.ts +++ b/packages/app/src/mobile-panels/model.test.ts @@ -17,6 +17,7 @@ class MobilePanelsScenario { private startedRevision = -1; private state: MobilePanelMotionState; readonly commits: MobilePanelCommit[] = []; + readonly animations: MobilePanelView[] = []; constructor(target: MobilePanelView = "agent") { this.state = createMobilePanelMotionState({ target, revision: this.nextRevision }); @@ -74,6 +75,9 @@ class MobilePanelsScenario { if (transition.commit) { this.commits.push(transition.commit); } + if (transition.animationTarget) { + this.animations.push(transition.animationTarget); + } } } @@ -237,6 +241,29 @@ describe("mobile panel ownership", () => { expect(panels.commits).toEqual([]); }); + it("returns an interrupted drag to the anchor when the command keeps its target", () => { + const panels = new MobilePanelsScenario(); + + panels.beginGesture("agent").command("agent").finishGesture("agent-list"); + + expect(panels.animations).toEqual(["agent"]); + expect(panels.snapshot()).toEqual({ + target: "agent", + motionTarget: "agent", + settledTarget: "agent", + revision: 1, + }); + expect(panels.commits).toEqual([]); + }); + + it("lets the command for a completed drag adopt the drag's finishing motion", () => { + const panels = new MobilePanelsScenario(); + + panels.beginGesture("agent").finishGesture("agent-list").command("agent-list"); + + expect(panels.animations).toEqual(["agent-list"]); + }); + it("keeps the latest rapid command when position reaches a stale anchor", () => { const panels = new MobilePanelsScenario(); diff --git a/packages/app/src/mobile-panels/model.ts b/packages/app/src/mobile-panels/model.ts index a7b821e82e..363bcfc450 100644 --- a/packages/app/src/mobile-panels/model.ts +++ b/packages/app/src/mobile-panels/model.ts @@ -78,7 +78,9 @@ export function transitionMobilePanel( if (event.selection.revision <= state.revision) { return { state }; } - const continuesCurrentMotion = state.motionTarget === event.selection.target; + // An interrupted drag left the position between anchors, so it moves back even when the + // target is unchanged. Without a drag, the command adopts motion already heading there. + const continuesCurrentMotion = !state.gesture && state.motionTarget === event.selection.target; return { animationTarget: continuesCurrentMotion ? undefined : event.selection.target, state: { diff --git a/packages/app/src/mobile-panels/presentation.tsx b/packages/app/src/mobile-panels/presentation.tsx index cb919e4595..7336e7c029 100644 --- a/packages/app/src/mobile-panels/presentation.tsx +++ b/packages/app/src/mobile-panels/presentation.tsx @@ -29,26 +29,27 @@ export function MobilePanelOverlay({ dockVisible = false, frameStyle, }: MobilePanelOverlayProps) { - const { position, windowWidth } = useMobilePanelsRuntime(); + const { leftPanelWidth, position, windowWidth } = useMobilePanelsRuntime(); const showMobileAgent = usePanelStore((state) => state.showMobileAgent); const overlayOpen = useIsMobilePanelActive(panel); const docked = presentation === "dock"; const isOpen = docked ? dockVisible : overlayOpen; const hostId = useId(); const isLeft = panel === "agent-list"; + const width = isLeft ? leftPanelWidth : windowWidth; const sidebarAnimatedStyle = useAnimatedStyle(() => { if (docked) return { transform: [{ translateX: 0 }] }; - const frame = getMobilePanelFrame(position.value, windowWidth); + const frame = getMobilePanelFrame(position.value, width); return { transform: [{ translateX: isLeft ? frame.leftTranslateX : frame.rightTranslateX }], }; - }, [docked, isLeft, windowWidth]); + }, [docked, isLeft, width]); const backdropAnimatedStyle = useAnimatedStyle(() => { - const frame = getMobilePanelFrame(position.value, windowWidth); + const frame = getMobilePanelFrame(position.value, width); return { opacity: isLeft ? frame.leftBackdropOpacity : frame.rightBackdropOpacity }; - }, [isLeft, windowWidth]); + }, [isLeft, width]); const overlayAnimatedStyle = useAnimatedStyle(() => { if (docked) return { opacity: 1, display: "flex" as const, pointerEvents: "box-none" as const }; @@ -73,11 +74,11 @@ export function MobilePanelOverlay({ () => [ docked ? styles.dockedPanel : styles.panel, !docked && positionedPanelStyle, - !docked && { width: windowWidth }, + !docked && { width }, panelStyle, sidebarAnimatedStyle, ], - [docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, windowWidth], + [docked, panelStyle, positionedPanelStyle, sidebarAnimatedStyle, width], ); let overlayPointerEvents: "auto" | "box-none" | "none"; if (!isWeb) { diff --git a/packages/app/src/mobile-panels/provider.tsx b/packages/app/src/mobile-panels/provider.tsx index b2f97a2547..77c00558ce 100644 --- a/packages/app/src/mobile-panels/provider.tsx +++ b/packages/app/src/mobile-panels/provider.tsx @@ -1,3 +1,6 @@ +import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout"; +import { subscribeMobilePanelSelection } from "./selection"; + import { createContext, useCallback, @@ -20,7 +23,10 @@ import { withTiming, type SharedValue, } from "react-native-reanimated"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { scheduleOnRN, scheduleOnUI } from "react-native-worklets"; +import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; import { usePanelStore, @@ -45,6 +51,7 @@ interface MobilePanelsRuntime { beginGesture: (input: BeginGestureInput) => number; finishGesture: (input: FinishGestureInput) => MobilePanelCommit | null; leftCloseGestureRef: RefObject; + leftPanelWidth: number; leftOpenGestureRef: RefObject; motionState: SharedValue; openGesturesBlocked: SharedValue; @@ -68,8 +75,36 @@ interface FinishGestureInput { const MobilePanelsContext = createContext(null); const MobilePanelActiveContext = createContext("agent"); +/** + * The left sidebar's width: the full window for the compact drawer, the saved sidebar width + * clamped to the safe viewport on wide layouts. The wide dock, its overlay, and the gestures that + * drag it all read this one value. + */ +function useResolvedLeftPanelWidth(windowWidth: number): number { + const isCompact = useIsCompactFormFactor(); + const insets = useSafeAreaInsets(); + const sidebarWidth = usePanelStore((state) => state.sidebarWidth); + const { height: windowHeight } = useWindowDimensions(); + const divisionLayout = resolveDesktopDivisionLayout({ + geometry: useWindowLayout(), + windowWidth, + windowHeight, + leftInset: insets.left, + rightInset: insets.right, + }); + if (isCompact) return windowWidth; + return ( + divisionLayout?.sidebarWidth ?? + resolveDesktopSidebarWidth({ + requestedWidth: sidebarWidth, + viewportWidth: windowWidth - insets.left - insets.right, + }) + ); +} + export function MobilePanelsProvider({ children }: { children: ReactNode }) { const { width: windowWidth } = useWindowDimensions(); + const leftPanelWidth = useResolvedLeftPanelWidth(windowWidth); const initialSelection = useRef(usePanelStore.getState().mobilePanel).current; const position = useSharedValue(getMobilePanelAnchor(initialSelection.target)); const motionState = useSharedValue(createMobilePanelMotionState(initialSelection)); @@ -152,11 +187,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { ); useEffect(() => { - return usePanelStore.subscribe((state, previousState) => { - const selection = state.mobilePanel; - if (selection === previousState.mobilePanel) { - return; - } + return subscribeMobilePanelSelection((selection) => { scheduleOnUI(applySelection, selection); }); }, [applySelection]); @@ -217,6 +248,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { finishGesture, leftCloseGestureRef, leftOpenGestureRef, + leftPanelWidth, motionState, openGesturesBlocked, position, @@ -228,6 +260,7 @@ export function MobilePanelsProvider({ children }: { children: ReactNode }) { [ beginGesture, finishGesture, + leftPanelWidth, motionState, openGesturesBlocked, position, @@ -255,6 +288,11 @@ export function useMobilePanelsRuntime(): MobilePanelsRuntime { return context; } +/** The resolved left sidebar width shared by the dock, the overlay, and their gestures. */ +export function useLeftPanelWidth(): number { + return useMobilePanelsRuntime().leftPanelWidth; +} + export function useIsMobilePanelActive(panel: MobilePanelView): boolean { return useContext(MobilePanelActiveContext) === panel; } diff --git a/packages/app/src/mobile-panels/selection.ts b/packages/app/src/mobile-panels/selection.ts new file mode 100644 index 0000000000..f47d9e737c --- /dev/null +++ b/packages/app/src/mobile-panels/selection.ts @@ -0,0 +1,14 @@ +import { usePanelStore, type MobilePanelSelection } from "@/stores/panel-store"; + +/** Observe panel commands, including layout commands published before subscription. */ +export function subscribeMobilePanelSelection( + onSelection: (selection: MobilePanelSelection) => void, +) { + const unsubscribe = usePanelStore.subscribe((state, previousState) => { + if (state.mobilePanel !== previousState.mobilePanel) { + onSelection(state.mobilePanel); + } + }); + onSelection(usePanelStore.getState().mobilePanel); + return unsubscribe; +} diff --git a/packages/app/src/navigation/host-runtime-bootstrap.test.ts b/packages/app/src/navigation/host-runtime-bootstrap.test.ts index 6e1e130090..fe7687886c 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.test.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.test.ts @@ -177,6 +177,7 @@ describe("resolveStartupRoute", () => { workspaceSelectionStatus: "unknown" as const, isWorkspaceSelectionLoaded: true, hasGivenUpWaitingForHost: false, + isWaitingForSetup: false, }; const baseHostInput = { route: { kind: "host" as const, serverId: "server-saved" }, @@ -278,6 +279,28 @@ describe("resolveStartupRoute", () => { ).toEqual({ kind: "redirect", href: "/h/srv-desktop" }); }); + it("holds the splash over the empty home while setup may still open", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "srv-desktop" }], + anyOnlineHostServerId: "srv-desktop", + isWaitingForSetup: true, + }), + ).toEqual({ kind: "splash" }); + }); + + it("restores a saved workspace without waiting for setup", () => { + expect( + resolveStartupRoute({ + ...baseIndexInput, + hosts: [{ serverId: "server-1" }], + workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" }, + isWaitingForSetup: true, + }), + ).toEqual({ kind: "redirect", href: "/h/server-1" }); + }); + it("keeps a known connecting host in app-owned routing instead of showing welcome", () => { expect( resolveStartupRoute({ diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 8ee1f7bfb2..c78b40c1cc 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -117,6 +117,8 @@ export interface ResolveIndexStartupRouteInput extends ResolveStartupRouteBaseIn workspaceSelectionStatus: WorkspaceSelectionStatus; isWorkspaceSelectionLoaded: boolean; hasGivenUpWaitingForHost: boolean; + /** Setup may still open over the empty home; see `useIsSetupDecisionPending`. */ + isWaitingForSetup: boolean; } export interface ResolveHostStartupRouteInput extends ResolveStartupRouteBaseInput { @@ -198,6 +200,12 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St }; } + // Without a workspace to restore, startup lands on the empty home. A new user's setup opens + // over it, so hold the splash until that is known instead of flashing the home first. + if (input.isWaitingForSetup) { + return { kind: "splash" }; + } + if (input.anyOnlineHostServerId) { return { kind: "redirect", href: buildHostRootRoute(input.anyOnlineHostServerId) }; } diff --git a/packages/app/src/onboarding/index.ts b/packages/app/src/onboarding/index.ts index 68c80d8f0d..78d3da6016 100644 --- a/packages/app/src/onboarding/index.ts +++ b/packages/app/src/onboarding/index.ts @@ -2,8 +2,10 @@ * First-run setup: a one-time modal that walks a new user through the surfaces Settings * already has — appearance, providers, projects, Paseo tools and phone pairing. * - * The app shell mounts the host; Settings reopens it. Whether it opens, which steps it has, - * and how completion is remembered stay inside; import from `@/onboarding` only. + * The app shell mounts the host, startup waits for its decision, and Settings reopens it. Whether + * it opens, which steps it has, and how completion is remembered stay inside; import from + * `@/onboarding` only. */ export { OnboardingHost } from "./onboarding-host"; export { useOpenOnboarding } from "./use-open-onboarding"; +export { useIsSetupDecisionPending } from "./use-setup-decision-pending"; diff --git a/packages/app/src/onboarding/use-setup-decision-pending.ts b/packages/app/src/onboarding/use-setup-decision-pending.ts new file mode 100644 index 0000000000..5918833ce6 --- /dev/null +++ b/packages/app/src/onboarding/use-setup-decision-pending.ts @@ -0,0 +1,24 @@ +import { useSyncExternalStore } from "react"; +import { isNative } from "@/constants/platform"; +import { useHostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import { getOnboardingModel } from "./internal/onboarding-instance"; +import { useSetupHostServerId } from "./internal/use-setup-host"; + +/** + * True while this launch may still open setup: it has not been decided, and the setup host is + * connecting, or connected and loading its workspaces. Startup keeps its loading screen up + * meanwhile, so a new user lands on setup rather than on the empty home first. False on phones, + * where setup never opens, when there is no setup host, and while the host is unreachable. + */ +export const useIsSetupDecisionPending: () => boolean = isNative + ? () => false + : useIsSetupDecisionPendingOnDesktop; + +function useIsSetupDecisionPendingOnDesktop(): boolean { + const model = getOnboardingModel(); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); + const serverId = useSetupHostServerId(); + const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? ""); + if (state.status !== "deciding" || !serverId) return false; + return connectionStatus !== "offline" && connectionStatus !== "error"; +} diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 99a38c2f8b..377d5331c8 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -31,7 +31,7 @@ import { import { useTranslation } from "react-i18next"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { deriveSubagentStatusBucket } from "@/subagents"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import type { TurnPresentation } from "@/timeline/turn-liveness"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -107,10 +107,7 @@ function useProviderSubagentDescriptor( titleState: descriptor ? "ready" : "loading", icon: useProviderIcon(provider, context.serverId), statusBucket: descriptor - ? deriveSidebarStateBucket({ - status: providerSubagentLifecycleStatus(descriptor.status), - requiresAttention: descriptor.status === "failed", - }) + ? deriveSubagentStatusBucket(providerSubagentLifecycleStatus(descriptor.status)) : null, }; } diff --git a/packages/app/src/plugins/buttons/view.tsx b/packages/app/src/plugins/buttons/view.tsx index 889d71001f..1876fef431 100644 --- a/packages/app/src/plugins/buttons/view.tsx +++ b/packages/app/src/plugins/buttons/view.tsx @@ -656,8 +656,8 @@ const styles = StyleSheet.create((theme) => ({ gap: { xs: theme.spacing[1], md: theme.spacing[2] }, }, headerButton: { - height: { xs: 32, md: HEADER_CONTROL_HEIGHT }, - minWidth: { xs: 32, md: HEADER_CONTROL_HEIGHT }, + height: HEADER_CONTROL_HEIGHT, + minWidth: HEADER_CONTROL_HEIGHT, paddingHorizontal: theme.spacing[2], flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 33e4f1a316..ca8e417fc3 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1189,6 +1189,151 @@ describe("HostRuntimeController", () => { expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient); }); + it.each([40, null])( + "keeps the selected connection when it reconnects before an alternative probe resolves with heartbeat RTT %s", + async (reconnectedRtt) => { + useHostRuntimeClock(); + const primary: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const alternative: HostConnection = { + id: "relay:relay-next.paseo.sh:443", + type: "relay", + relayEndpoint: "relay-next.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const host = makeHost({ + connections: [primary, alternative], + preferredConnectionId: primary.id, + }); + const activeClient = new FakeDaemonClient(); + activeClient.setConnectionState({ status: "connected" }); + activeClient.heartbeatReportsRtt(40); + // The first alternative probe (startup) answers immediately; the one that + // runs after the foreground is slow, like a relay handshake on mobile. + const slowAlternativeProbe = createDeferred(); + let alternativeProbes = 0; + const createdClients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + const client = new FakeDaemonClient(); + createdClients.push(client); + return client as unknown as DaemonClient; + }, + connectToDaemon: async ({ connection }) => { + if (connection.id !== alternative.id) { + throw new Error(`unexpected probe for ${connection.id}`); + } + alternativeProbes += 1; + if (alternativeProbes > 1) await slowAlternativeProbe.promise; + return { + client: makeConnectedProbeClient(60) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: primary.id, + existingClient: activeClient as unknown as DaemonClient, + }, + }); + expect(controller.getSnapshot().activeConnectionId).toBe(primary.id); + + // The relay closed the socket while the app was in the background. + activeClient.setConnectionState({ status: "disconnected", reason: "1012" }); + await vi.advanceTimersByTimeAsync(130_000); + + // Foreground: the probe cycle starts while the selected client is still down ... + const cycle = controller.runProbeCycleNow(); + await vi.advanceTimersByTimeAsync(1_000); + expect(alternativeProbes).toBe(2); + // ... the selected client reconnects ... + activeClient.heartbeatReportsRtt(reconnectedRtt); + activeClient.setConnectionState({ status: "connected" }); + expect(controller.getSnapshot().connectionStatus).toBe("online"); + const statuses: string[] = []; + controller.subscribe(() => statuses.push(controller.getSnapshot().connectionStatus)); + // ... and only then does the alternative probe come back. + slowAlternativeProbe.resolve(); + await cycle; + + // Once the selected connection is back online, nothing should take it down again. + expect(statuses.filter((status) => status !== "online")).toEqual([]); + expect(controller.getSnapshot().activeConnectionId).toBe(primary.id); + expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient); + expect(activeClient.isDisposed()).toBe(false); + expect(createdClients).toEqual([]); + }, + ); + + it("fails over when the selected connection stays down until an alternative probe resolves", async () => { + useHostRuntimeClock(); + const primary: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const alternative: HostConnection = { + id: "relay:relay-next.paseo.sh:443", + type: "relay", + relayEndpoint: "relay-next.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const host = makeHost({ + connections: [primary, alternative], + preferredConnectionId: primary.id, + }); + const activeClient = new FakeDaemonClient(); + activeClient.setConnectionState({ status: "connected" }); + const slowAlternativeProbe = createDeferred(); + let alternativeProbes = 0; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => new FakeDaemonClient() as unknown as DaemonClient, + connectToDaemon: async () => { + alternativeProbes += 1; + if (alternativeProbes > 1) await slowAlternativeProbe.promise; + return { + client: makeConnectedProbeClient(60) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: primary.id, + existingClient: activeClient as unknown as DaemonClient, + }, + }); + + activeClient.setConnectionState({ status: "disconnected", reason: "1012" }); + await vi.advanceTimersByTimeAsync(130_000); + const cycle = controller.runProbeCycleNow(); + await vi.advanceTimersByTimeAsync(1_000); + slowAlternativeProbe.resolve(); + await cycle; + + expect(controller.getSnapshot().activeConnectionId).toBe(alternative.id); + expect(activeClient.isDisposed()).toBe(true); + await controller.stop(); + }); + it("rejects probes that resolve to a different server id", async () => { const host = makeHost({ serverId: "srv_old", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 0c7c9f4660..4d31258aaa 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1021,6 +1021,9 @@ export class HostRuntimeController { } const currentActiveConnectionId = this.snapshot.activeConnectionId; + if (currentActiveConnectionId) { + this.reconcileActiveProbeWithClient(currentActiveConnectionId, probeByConnectionId); + } const activeProbe = currentActiveConnectionId ? probeByConnectionId.get(currentActiveConnectionId) : null; @@ -1211,6 +1214,27 @@ export class HostRuntimeController { }); } + // The active connection is probed through its own client, which may still be + // reconnecting when the probe starts. A verdict taken then is stale if the client + // has since connected, so the decision uses the client's current state. + private reconcileActiveProbeWithClient( + connectionId: string, + probeByConnectionId: Map, + ): void { + if (probeByConnectionId.get(connectionId)?.status !== "unavailable") return; + if (this.authRejectedConnectionIds.has(connectionId)) return; + const client = this.snapshot.client; + if (!client || client.getConnectionState().status !== "connected") return; + const rttMs = client.getLastLivenessRttMs(); + probeByConnectionId.set( + connectionId, + rttMs === null + ? { status: "pending", latencyMs: null } + : { status: "available", latencyMs: rttMs }, + ); + this.updateSnapshot({ probeByConnectionId: new Map(probeByConnectionId) }); + } + private isConnectionUnavailable( connectionId: string, probe: ConnectionProbeState | null | undefined, diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 0ea262f7cb..5c90ffa5e1 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -321,6 +321,26 @@ describe("ReplicaCache", () => { expect(restored?.range).toBeNull(); }); + it("preserves workspace parentage and its checkpoint across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } }); + const workspace = normalizeWorkspaceDescriptor({ + ...workspacePayload(), + parentWorkspaceId: "parent", + }); + baseline.workspaces.set(workspace.id, workspace); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + const reader = createCache(storage); + const restored = await reader.readDirectory(SERVER_ID); + expect(restored.checkpoint).toEqual(baseline.checkpoint); + expect(restored.workspaces.get(workspace.id)?.parentWorkspaceId).toBe("parent"); + expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace.parentWorkspaceId).toBe( + "parent", + ); + }); + it("preserves workspace sorting timestamps across cache reopen", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 1032a663bf..91a41e4186 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -299,6 +299,7 @@ const WorkspaceGitRuntimeSchema = z .optional(); const StoredWorkspaceSchema = z.strictObject({ + parentWorkspaceId: z.string().optional(), // Require both keys: a cursor without these values would preserve the wrong sort on reconnect. createdAt: IsoDateSchema.nullable(), lastUserMessageAt: IsoDateSchema.nullable(), @@ -707,6 +708,7 @@ function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor { function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { + parentWorkspaceId: workspace.parentWorkspaceId, createdAt: workspace.createdAt?.toISOString() ?? null, lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null, id: workspace.id, diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx index 3114d7bd79..331e274882 100644 --- a/packages/app/src/screens/new-workspace/import-session-button.tsx +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -33,11 +33,12 @@ export function ImportSessionButton({ } const styles = StyleSheet.create((theme) => ({ - // The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's - // border lines up with the composer's border. + // The composer sits spacing[4] in from the dock; the pill's border lines up with the + // composer's border, spacing[4] below it. wide: { alignItems: "flex-start", paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[4], }, pill: { borderRadius: theme.borderRadius.full, diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 8bce0c4dd1..9c8b230e4c 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -125,7 +125,7 @@ import { SETTINGS_DESKTOP_SIDEBAR_WIDTH, useIsCompactFormFactor, } from "@/constants/layout"; -import { densityIconBox, densityIconSize } from "@/styles/density"; +import { density, densityIconBox, densityIconSize } from "@/styles/density"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { type EnableBuiltInDaemonOption, @@ -1523,7 +1523,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: { xs: theme.spacing[3], md: theme.spacing[2] }, - minHeight: { xs: 52, md: 28 }, + minHeight: { xs: 52, md: density.tightRowHeight.md }, paddingVertical: theme.spacing[1], paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] }, borderRadius: { xs: 0, md: theme.borderRadius.lg }, @@ -1549,7 +1549,7 @@ const sidebarStyles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: { xs: theme.spacing[3], md: theme.spacing[2] }, - minHeight: { xs: 52, md: 28 }, + minHeight: { xs: 52, md: density.tightRowHeight.md }, paddingVertical: theme.spacing[1], paddingHorizontal: { xs: theme.spacing[4], md: theme.spacing[2] }, borderRadius: { xs: 0, md: theme.borderRadius.lg }, diff --git a/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx new file mode 100644 index 0000000000..668f6345a8 --- /dev/null +++ b/packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx @@ -0,0 +1,38 @@ +import { useCallback, useState, type ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import { selectWorkspaceChrome } from "./workspace-tab-layout"; +import { WorkspaceTabMeasurementScope } from "./workspace-desktop-tabs-row"; + +export function AdaptiveWorkspaceChrome({ + compactHeader, + children, +}: { + compactHeader: ReactNode; + children: ReactNode; +}) { + const [width, setWidth] = useState(null); + const measure = useCallback((event: LayoutChangeEvent) => { + const measured = event.nativeEvent.layout.width; + if (measured > 0) setWidth(measured); + }, []); + return ( + + + {selectWorkspaceChrome({ width, compact: compactHeader, full: children })} + + + ); +} + +export interface WorkspaceCompactHeaderActions { + pane?: { + id: string; + splitRight?: () => void; + splitDown?: () => void; + toggleMaximized?: () => void; + maximized: boolean; + }; + closeAbove: (key: string) => Promise | void; + closeBelow: (key: string) => Promise | void; + closeOthers: (key: string) => Promise | void; +} diff --git a/packages/app/src/screens/workspace/compact-workspace-header/index.tsx b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx index 2a7c4a6a57..1865bc5c93 100644 --- a/packages/app/src/screens/workspace/compact-workspace-header/index.tsx +++ b/packages/app/src/screens/workspace/compact-workspace-header/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, type ReactNode } from "react"; +import { Fragment, useMemo, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { CompactHeader } from "@/components/headers/compact-header"; import { @@ -6,6 +6,13 @@ import { WorkspaceExplorerToggle, } from "@/screens/workspace/workspace-explorer-toggle"; import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; +import { + useWindowLayout, + resolveWorkspaceActionPlacement, + resolveWorkspaceBarActions, + WindowLayoutActions, +} from "@/window-layout"; +import { IconButtonChromeProvider } from "@/components/ui/icon-button-chrome"; export { MobileWorkspaceTabSwitcher } from "./tab-switcher"; @@ -18,8 +25,11 @@ interface CompactWorkspaceHeaderProps { onToggleExplorer: () => void; explorerToggleLabel: string; explorerToggleAccessibilityState: { expanded: boolean }; - /** Tab switcher and workspace menu, before the explorer toggle. */ + tabSwitcher: ReactNode; + /** Workspace overflow menu. */ children: ReactNode; + headerVariant: "compact" | "full"; + actionsActive: boolean; } /** The compact header with the workspace's project row and controls. */ @@ -32,9 +42,13 @@ export function CompactWorkspaceHeader({ explorerToggleLabel, explorerToggleAccessibilityState, children, + tabSwitcher, serverId, + headerVariant, + actionsActive, }: CompactWorkspaceHeaderProps) { const { t } = useTranslation(); + const actionsInBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar"; const projectRow = useMemo( () => ( - {children} + const symbols = { + "tab-count": tabSwitcher, + overflow: children, + explorer: ( - - ); + ), + }; + const actions = resolveWorkspaceBarActions(headerVariant).map((symbol) => ( + {symbols[symbol]} + )); return ( - + + {headerVariant === "compact" ? ( + + ) : null} + {actions} + ); } diff --git a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx index 737e92b4c2..334a166aef 100644 --- a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx +++ b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx @@ -1,3 +1,4 @@ +import { workspaceTabSwitcherPopoverLayout } from "../workspace-tab-layout"; import { memo, useCallback, useMemo, useRef, useState } from "react"; import { Keyboard, Pressable, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -123,14 +124,15 @@ function getFallbackTabOptionDescription( } interface MobileWorkspaceTabSwitcherProps { + paneId?: string; tabs: WorkspaceTabDescriptor[]; activeTabKey: string; normalizedServerId: string; normalizedWorkspaceId: string; onSelectSwitcherTab: (key: string) => void; createTerminalDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; + onCreateDraftTab: (input?: { paneId?: string }) => void; + onCreateTerminal: (input?: { paneId?: string }) => void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onCopyTerminalId: (terminalId: string) => Promise | void; @@ -285,6 +287,7 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch createTerminalDisabled, onCreateDraftTab, onCreateTerminal, + paneId, onCopyResumeCommand, onCopyAgentId, onCopyTerminalId, @@ -342,13 +345,13 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch const handleNewAgent = useCallback(() => { setIsOpen(false); - onCreateDraftTab(); - }, [onCreateDraftTab]); + onCreateDraftTab({ paneId }); + }, [onCreateDraftTab, paneId]); const handleNewTerminal = useCallback(() => { setIsOpen(false); - onCreateTerminal(); - }, [onCreateTerminal]); + onCreateTerminal({ paneId }); + }, [onCreateTerminal, paneId]); const footer = useMemo( () => ( @@ -456,6 +459,7 @@ export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitch (null); const [resizePressed, setResizePressed] = useState(false); - useEffect(() => { - resizeWidth.value = width; - }, [resizeWidth, width]); + useAnimatedReaction( + () => ({ resize: resize.value, width, containerWidth, overlay, resizeEnabled }), + ({ resize: currentResize, ...allocation }) => { + const nextResize = reconcileExplorerSidebarResize(currentResize, allocation); + if (nextResize !== currentResize) resize.value = nextResize; + }, + [containerWidth, overlay, resize, resizeEnabled, width], + ); const showResizeGrip = useCallback(() => setResizePressed(true), []); const hideResizeGrip = useCallback(() => setResizePressed(false), []); @@ -37,22 +55,50 @@ export function useExplorerGeometry({ .activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET]) .failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET]) .onStart((event) => { - startWidth.value = width + event.translationX; - resizeWidth.value = width; + const currentWidth = resolveExplorerSidebarFrameWidth({ + width, + containerWidth, + overlay, + resizeEnabled, + resize: resize.value, + resizeWidth: resizeWidth.value, + }); + resize.value = beginExplorerSidebarResize(resize.value, { + width, + containerWidth, + overlay, + resizeEnabled, + }); + startWidth.value = currentWidth + event.translationX; + resizeWidth.value = currentWidth; }) .onUpdate((event) => { + if (!resize.value) return; resizeWidth.value = resolveExplorerSidebarWidth({ requestedWidth: startWidth.value - event.translationX, containerWidth, }); }) - .onEnd(() => runOnJS(onResize)(resizeWidth.value)) - .onFinalize(() => scheduleOnRN(hideResizeGrip)), + .onEnd((_event, success) => { + { + resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, success); + if (!success || !resize.value) return; + } + runOnJS(onResize)(resizeWidth.value); + }) + .onFinalize((_event, success) => { + if (!success) { + resize.value = finishExplorerSidebarResize(resize.value, resizeWidth.value, false); + } + scheduleOnRN(hideResizeGrip); + }), [ resizeEnabled, onResize, containerWidth, hideResizeGrip, + overlay, + resize, resizeWidth, showResizeGrip, startWidth, @@ -61,12 +107,21 @@ export function useExplorerGeometry({ ); // Reanimated retains the last animated value when its style is removed. Keep this // style attached across folding and explicitly size overlays to the workspace viewport. - const animatedWidthStyle = useAnimatedStyle( - () => ({ - width: overlay ? containerWidth : resizeWidth.value, - }), - [containerWidth, overlay], - ); + const animatedWidthStyle = useAnimatedStyle(() => { + if (overlay) return { width: containerWidth }; + { + return { + width: resolveExplorerSidebarFrameWidth({ + width, + containerWidth, + overlay, + resizeEnabled, + resize: resize.value, + resizeWidth: resizeWidth.value, + }), + }; + } + }, [containerWidth, overlay, resizeEnabled, width]); return { frameStyle: animatedWidthStyle, panelStyle: { diff --git a/packages/app/src/screens/workspace/scripts/index.ts b/packages/app/src/screens/workspace/scripts/index.ts index efff482e80..bbe223f520 100644 --- a/packages/app/src/screens/workspace/scripts/index.ts +++ b/packages/app/src/screens/workspace/scripts/index.ts @@ -8,3 +8,5 @@ export { type UseWorkspaceScriptsMenuPagesInput, type WorkspaceScriptsMenuPages, } from "./workspace-scripts-menu-pages"; + +export { useWorkspaceScriptActions } from "./use-workspace-script-actions"; diff --git a/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts index fb7809990f..6752f02719 100644 --- a/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts +++ b/packages/app/src/screens/workspace/scripts/use-workspace-script-actions.ts @@ -3,9 +3,9 @@ import * as Clipboard from "expo-clipboard"; import { useMutation } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; -import { useToast } from "@/contexts/toast-context"; +import { useToast } from "@/contexts/toast-api-context"; import { resolveWorkspaceScriptLink, type WorkspaceScriptLinkKind, @@ -34,6 +34,7 @@ export interface WorkspaceScriptActions { } export interface UseWorkspaceScriptActionsInput { + client: Pick | null; serverId: string; workspaceId: string; scripts: WorkspaceDescriptor["scripts"]; @@ -45,10 +46,11 @@ export interface UseWorkspaceScriptActionsInput { * lists them. * * A restart outlives the press that asked for it — the second half waits for the daemon to - * report the script stopped — so mount this with whatever owns the menu, not inside a menu page - * that unmounts when the menu closes. + * report the script stopped — so mount this once with the workspace screen and pass its actions to + * each menu surface. Header placement and menu lifetime must not own pending restarts. */ export function useWorkspaceScriptActions({ + client, serverId, workspaceId, scripts, @@ -56,7 +58,6 @@ export function useWorkspaceScriptActions({ }: UseWorkspaceScriptActionsInput): WorkspaceScriptActions { const { t } = useTranslation(); const toast = useToast(); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore( (state) => state.byServerId[serverId] ?? null, diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx index 290f424e97..dbe4b1ab92 100644 --- a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.test.tsx @@ -8,6 +8,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createRoot } from "react-dom/client"; +import { ToastApiProvider } from "@/contexts/toast-api-context"; +import { useWorkspaceScriptActions } from "./use-workspace-script-actions"; import { WorkspaceScriptsButton } from "./workspace-scripts-button"; void testI18n; @@ -50,7 +52,13 @@ const { return { theme: hoistedTheme, - startWorkspaceScriptMock: vi.fn(async () => ({ terminalId: "terminal-script-1" })), + startWorkspaceScriptMock: vi.fn(async () => ({ + terminalId: "terminal-script-1", + requestId: "start-1", + workspaceId: "workspace-1", + scriptName: "dev", + error: null, + })), killTerminalMock: vi.fn(async () => ({ terminalId: "terminal-script-1", success: true, @@ -113,20 +121,6 @@ vi.mock("@/workspace-service-routes/store", async () => { }; }); -vi.mock("@/stores/session-store", () => ({ - useSessionStore: (selector: (state: unknown) => unknown) => - selector({ - sessions: { - "test-server": { - client: { - startWorkspaceScript: startWorkspaceScriptMock, - killTerminal: killTerminalMock, - }, - }, - }, - }), -})); - vi.mock("@/contexts/toast-context", () => ({ useToast: () => ({ show: vi.fn(), error: vi.fn(), copied: copiedToastMock }), })); @@ -237,6 +231,34 @@ interface RenderScriptsOptions { hideLabels?: boolean; } +const testToast = { show: () => () => {}, error: () => {}, copied: copiedToastMock }; +const scriptClient = { + startWorkspaceScript: startWorkspaceScriptMock, + killTerminal: killTerminalMock, +}; +function ScriptsOwner({ + scripts, + hideLabels, +}: { + scripts: WorkspaceScriptPayload[]; + hideLabels?: boolean; +}) { + const actions = useWorkspaceScriptActions({ + client: scriptClient, + serverId: "test-server", + workspaceId: "workspace-1", + scripts, + }); + return ( + + ); +} + function renderScripts( scripts: WorkspaceScriptPayload[], options: RenderScriptsOptions = {}, @@ -257,13 +279,9 @@ function renderScripts( function element(nextScripts: WorkspaceScriptPayload[]): ReactElement { return ( - + + + ); } diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx index 6acd0dfe53..8e922ee2ab 100644 --- a/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-button.tsx @@ -28,23 +28,18 @@ import type { WorkspaceScriptLinkTarget, } from "@/utils/workspace-script-links"; import type { Theme } from "@/styles/theme"; -import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; import { extraMutedIconColorMapping } from "@/components/ui/icon-color"; import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks"; import { scriptToneColorMapping } from "./script-status"; -import { - useWorkspaceScriptActions, - type WorkspaceScriptActions, -} from "./use-workspace-script-actions"; +import type { WorkspaceScriptActions } from "./use-workspace-script-actions"; type RowActionIcon = "copy" | "open" | "restart" | "start" | "stop" | "terminal"; interface WorkspaceScriptsButtonProps { - serverId: string; - workspaceId: string; + actions: WorkspaceScriptActions; scripts: WorkspaceDescriptor["scripts"]; liveTerminalIds?: readonly string[]; - onScriptTerminalStarted?: (terminalId: string) => void; onViewTerminal?: (terminalId: string) => void; onOpenUrlInBrowserTab?: (url: string) => void; hideLabels?: boolean; @@ -448,22 +443,14 @@ function ScriptRow({ } export function WorkspaceScriptsButton({ - serverId, - workspaceId, + actions, scripts, liveTerminalIds = [], - onScriptTerminalStarted, onViewTerminal, onOpenUrlInBrowserTab, hideLabels, }: WorkspaceScriptsButtonProps): ReactElement | null { const { t } = useTranslation(); - const actions = useWorkspaceScriptActions({ - serverId, - workspaceId, - scripts, - onScriptTerminalStarted, - }); const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]); const triggerStyle = useCallback( @@ -531,10 +518,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, splitButton: { - height: { - xs: CONTROL_HEIGHTS.sm, - md: HEADER_CONTROL_HEIGHT, - }, + height: HEADER_CONTROL_HEIGHT, flexDirection: "row", alignItems: "stretch", borderRadius: theme.borderRadius.md, diff --git a/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx index 3d14d9faff..0dd6fe5400 100644 --- a/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx +++ b/packages/app/src/screens/workspace/scripts/workspace-scripts-menu-pages.tsx @@ -18,10 +18,7 @@ import type { } from "@/utils/workspace-script-links"; import { ExitCodeBadge, routeLabelKey, ScriptIcon } from "./script-marks"; import { scriptToneColorMapping, summarizeScripts, type ScriptsSummary } from "./script-status"; -import { - useWorkspaceScriptActions, - type WorkspaceScriptActions, -} from "./use-workspace-script-actions"; +import type { WorkspaceScriptActions } from "./use-workspace-script-actions"; type WorkspaceScript = WorkspaceDescriptor["scripts"][number]; @@ -56,11 +53,9 @@ function scriptPageId(scriptName: string): string { } export interface UseWorkspaceScriptsMenuPagesInput { - serverId: string; - workspaceId: string; + actions: WorkspaceScriptActions; scripts: WorkspaceDescriptor["scripts"]; liveTerminalIds: readonly string[]; - onScriptTerminalStarted: (terminalId: string) => void; onViewTerminal: (terminalId: string) => void; onOpenUrlInBrowserTab: (url: string) => void; } @@ -80,25 +75,16 @@ export interface WorkspaceScriptsMenuPages { * Every script gets a page whether or not it runs, so stopping one from its own page flips that * page to its stopped state instead of pulling it out from under the user. * - * The actions live here rather than in a page, so call this from whatever owns the menu: a restart - * finishes after the sheet has closed. + * The workspace screen owns the actions so a restart finishes across menu and header changes. */ export function useWorkspaceScriptsMenuPages({ - serverId, - workspaceId, + actions, scripts, liveTerminalIds, - onScriptTerminalStarted, onViewTerminal, onOpenUrlInBrowserTab, }: UseWorkspaceScriptsMenuPagesInput): WorkspaceScriptsMenuPages { const { t } = useTranslation(); - const actions = useWorkspaceScriptActions({ - serverId, - workspaceId, - scripts, - onScriptTerminalStarted, - }); const summary = useMemo(() => summarizeScripts(scripts), [scripts]); const trigger = useMemo( diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index bf957a27d4..3c138087c0 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,6 +1,8 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { Fragment, + createContext, + useContext, useCallback, useEffect, useLayoutEffect, @@ -82,6 +84,7 @@ import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu"; import { + paneContentToolbarIconSize, paneContentToolbarTrailingPadding, ToolbarButton, ToolbarControls, @@ -115,6 +118,8 @@ const TAB_ICON_WIDTH = 14; const TAB_CONTENT_GAP = 4; const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_MODIFIED_DOT_SIZE = 8; +// The tab rail only renders in the wide layout, so the device alone picks its glyph size. +const TAB_RAIL_GLYPH_SIZE = paneContentToolbarIconSize(false); const TAB_MIN_WIDTH = 64; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; @@ -249,7 +254,7 @@ function WorkspaceNewTabButton({ testID="workspace-new-tab-button" style={placement === "inline" ? styles.inlineNewTabButton : undefined} > - + , + () => , [], ); const splitDownLeading = useMemo( - () => , + () => , [], ); const splitRightTrailing = useMemo( @@ -341,9 +346,9 @@ function WorkspacePaneToolbarActions({ onPress={onTogglePaneMaximized} > {paneMaximized ? ( - + ) : ( - + )} ) : null} @@ -354,7 +359,7 @@ function WorkspacePaneToolbarActions({ label={t("workspace.git.actions.moreActions")} testID="workspace-split-pane-menu" > - + - + ); @@ -485,6 +490,13 @@ interface WorkspaceTabLabelMeasurement { width: number; } +const TabMeasurementCache = createContext | null>(null); + +export function WorkspaceTabMeasurementScope({ children }: { children: React.ReactNode }) { + const cache = useMemo(() => new Map(), []); + return {children}; +} + interface WorkspaceTabTrackSnapshot { signature: string; tabs: ResolvedWorkspaceDesktopTabRowItem[]; @@ -958,6 +970,47 @@ function TabChip({ ); } +export function useWorkspaceNewTabKeyboardAction({ + handlerName = "workspace-new-tab", + ownsKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, +}: { + handlerName?: string; + ownsKeyboardShortcuts: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + paneId?: string; + createNewTab: () => void; +}) { + const handleNewTabKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!ownsKeyboardShortcuts) return false; + if (action.id === "workspace.tab.menu.open") { + createNewTab(); + return true; + } + return false; + }, + [createNewTab, ownsKeyboardShortcuts], + ); + + useKeyboardActionHandler({ + handlerId: buildWorkspaceKeyboardHandlerId({ + name: handlerName, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + paneId, + }), + actions: ["workspace.tab.menu.open"], + enabled: ownsKeyboardShortcuts, + priority: 200, + handle: handleNewTabKeyboardAction, + }); +} + export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { const [presentations, setPresentations] = useState( () => new Map(), @@ -1059,9 +1112,13 @@ function ResolvedWorkspaceDesktopTabsRow({ const tabsScrollRef = useRef(null); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); const tabScrollBoundary = useHorizontalScrollBoundary(); - const [labelMeasurements, setLabelMeasurements] = useState( - () => new Map(), - ); + const measurementCache = useContext(TabMeasurementCache); + const [labelMeasurements, setLabelMeasurements] = useState(() => new Map(measurementCache ?? [])); + useLayoutEffect(() => { + if (!measurementCache) return; + measurementCache.clear(); + for (const [key, value] of labelMeasurements) measurementCache.set(key, value); + }, [measurementCache, labelMeasurements]); const [trackSnapshot, setTrackSnapshot] = useState(null); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { @@ -1246,29 +1303,12 @@ function ResolvedWorkspaceDesktopTabsRow({ const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]); - const handleNewTabKeyboardAction = useCallback( - (action: KeyboardActionDefinition): boolean => { - if (!ownsKeyboardShortcuts) return false; - if (action.id === "workspace.tab.menu.open") { - createNewTab(); - return true; - } - return false; - }, - [createNewTab, ownsKeyboardShortcuts], - ); - - useKeyboardActionHandler({ - handlerId: buildWorkspaceKeyboardHandlerId({ - name: "workspace-new-tab", - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - paneId, - }), - actions: ["workspace.tab.menu.open"], - enabled: ownsKeyboardShortcuts, - priority: 200, - handle: handleNewTabKeyboardAction, + useWorkspaceNewTabKeyboardAction({ + ownsKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, }); const rail: WorkspaceTabRailSurface = host === "explorer" ? "sidebar" : "surface"; diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index b8f4739cfb..cca5dd2e10 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -1,3 +1,4 @@ +import type { WorkspaceCompactHeaderActions } from "./adaptive-workspace-chrome"; import React, { useCallback, useMemo } from "react"; import { View } from "react-native"; import { useRouter, type Href } from "expo-router"; @@ -206,13 +207,15 @@ function HeaderMenuProfileItem({ } export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspaceActions { + pane?: WorkspaceCompactHeaderActions["pane"]; + onExitFocusMode?: () => void; normalizedServerId: string; normalizedWorkspaceId: string; scripts: UseWorkspaceScriptsMenuPagesInput; showCreateBrowserTab: boolean; createTerminalDisabled: boolean; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; - onCreateBrowser: () => void; + onCreateTerminalWithProfile: (profile: TerminalProfile, paneId?: string) => void; + onCreateBrowser: (input?: { paneId?: string }) => void; } /** @@ -231,8 +234,19 @@ export function WorkspaceHeaderMenuMobile({ createTerminalDisabled, onCreateTerminalWithProfile, onCreateBrowser, + pane, + onExitFocusMode, ...workspaceActions }: WorkspaceHeaderMenuMobileProps) { + const paneId = pane?.id; + const handleCreateBrowser = useCallback( + () => onCreateBrowser({ paneId }), + [onCreateBrowser, paneId], + ); + const handleCreateTerminalWithProfile = useCallback( + (profile: TerminalProfile) => onCreateTerminalWithProfile(profile, paneId), + [onCreateTerminalWithProfile, paneId], + ); const { t } = useTranslation(); const router = useRouter(); const triggerStyle = useIconButtonChromeStyle({ size: "large" }); @@ -278,6 +292,30 @@ export function WorkspaceHeaderMenuMobile({ pages={pages} sheetTitle={t("workspace.header.actions.workspaceActions")} > + {onExitFocusMode ? ( + + {t("workspace.tabs.actions.exitFocusMode")} + + ) : null} + {pane?.splitRight ? ( + + {t("workspace.tabs.actions.splitRight")} + + ) : null} + {pane?.splitDown ? ( + + {t("workspace.tabs.actions.splitDown")} + + ) : null} + {pane?.toggleMaximized ? ( + + {t( + pane.maximized + ? "workspace.tabs.actions.restorePane" + : "workspace.tabs.actions.maximizePane", + )} + + ) : null} {scriptsMenu.trigger} {pluginMenu.rows} {pluginMenu.rows.length > 0 ? : null} @@ -285,7 +323,7 @@ export function WorkspaceHeaderMenuMobile({ {t("workspace.header.actions.newBrowser")} @@ -298,7 +336,7 @@ export function WorkspaceHeaderMenuMobile({ key={profile.id} profile={profile} disabled={createTerminalDisabled} - onCreateTerminalWithProfile={onCreateTerminalWithProfile} + onCreateTerminalWithProfile={handleCreateTerminalWithProfile} /> ))} diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 2aa5a54bae..2d6f2bfeac 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,7 @@ +import { + AdaptiveWorkspaceChrome, + type WorkspaceCompactHeaderActions, +} from "./adaptive-workspace-chrome"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; import { @@ -7,6 +11,7 @@ import { type InlinePathTarget, } from "@/assistant-file-links"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; +import { useWindowLayout, resolveWorkspaceActionPlacement } from "@/window-layout"; import { revealWorkspaceTab } from "@/workspace-tabs/navigation"; import type { LayoutChangeEvent } from "react-native"; import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar"; @@ -62,7 +67,7 @@ import { SplitContainer } from "@/components/split-container"; import { RetainedPanel } from "@/components/retained-panel"; import { WorkspaceActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button"; -import { WorkspaceScriptsButton } from "@/screens/workspace/scripts"; +import { WorkspaceScriptsButton, useWorkspaceScriptActions } from "@/screens/workspace/scripts"; import { ImportSessionSheet } from "@/components/import-session-sheet"; import { useNavigateToImportedAgent } from "@/hooks/use-import-session"; import { useToast } from "@/contexts/toast-context"; @@ -147,6 +152,7 @@ import { } from "@/screens/workspace/use-workspace-tab-rename"; import { WorkspaceDesktopTabsRow, + useWorkspaceNewTabKeyboardAction, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; @@ -502,6 +508,7 @@ function WorkspaceHeaderTitleBar({ onCopyBranchName, onOpenSetupTab, }: WorkspaceHeaderTitleBarProps) { + const actionsInBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar"; return ( {isLoading ? ( @@ -518,18 +525,20 @@ function WorkspaceHeaderTitleBar({ /> )} - - - + {actionsInBar ? null : ( + + + + )} ); } @@ -1022,8 +1031,11 @@ function WorkspaceScreenContent({ }: WorkspaceScreenContentProps) { const { t } = useTranslation(); const _insets = useSafeAreaInsets(); + const workspaceActionsInBar = + resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar"; const toast = useToast(); const isMobile = useIsCompactFormFactor(); + const workspacePanelActive = usePanelStore((state) => state.mobilePanel.target === "agent"); const canRenderDesktopPaneSplits = supportsDesktopPaneSplits(); const layoutMode = useWorkspaceLayoutStore((state) => state.layoutMode); const hasMacTrafficLights = useHasWindowChromeObstruction("top-left"); @@ -1213,6 +1225,13 @@ function WorkspaceScreenContent({ onTerminalCreateQueued: handleTerminalCreateQueued, onTerminalCreateFailed: handleTerminalCreateFailed, }); + const scriptActions = useWorkspaceScriptActions({ + client, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + scripts: workspaceScripts, + onScriptTerminalStarted: handleScriptTerminalStarted, + }); const { archiveAgent } = useArchiveAgent(); const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({ @@ -1794,8 +1813,8 @@ function WorkspaceScreenContent({ }); const handleCreateTerminalWithProfile = useCallback( - (profile: TerminalProfile) => { - createTerminal({ profile, destination: { kind: "open" } }); + (profile: TerminalProfile, paneId?: string) => { + createTerminal({ profile, destination: { kind: "open", paneId } }); }, [createTerminal], ); @@ -3189,11 +3208,9 @@ function WorkspaceScreenContent({ {workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( - + {workspaceActionsInBar ? null : ( + + )} ) : null} @@ -3231,13 +3250,14 @@ function WorkspaceScreenContent({ workspaceDirectory, activeFileLocation, liveTerminalIds, - handleScriptTerminalStarted, + scriptActions, handleViewScriptTerminal, handleOpenUrlInBrowserTab, handleToggleExplorerSidebar, explorerSidebarToggleLabel, explorerSidebarToggleAccessibilityState, explorerToggleOwner, + workspaceActionsInBar, t, ], ); @@ -3343,28 +3363,79 @@ function WorkspaceScreenContent({ ); const compactHeaderScripts = useMemo( () => ({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, + actions: scriptActions, scripts: workspaceScripts, liveTerminalIds, - onScriptTerminalStarted: handleScriptTerminalStarted, onViewTerminal: handleViewScriptTerminal, onOpenUrlInBrowserTab: handleOpenUrlInBrowserTab, }), [ - normalizedServerId, - normalizedWorkspaceId, workspaceScripts, liveTerminalIds, - handleScriptTerminalStarted, + scriptActions, handleViewScriptTerminal, handleOpenUrlInBrowserTab, ], ); - const compactWorkspaceHeader = useMemo( - () => - isMobile ? ( + const renderWorkspaceTabSwitcher = useCallback( + ( + headerTabs: WorkspaceTabDescriptor[], + headerActiveTabKey: string, + actions: WorkspaceCompactHeaderActions, + ) => { + return ( + + ); + }, + [ + normalizedServerId, + normalizedWorkspaceId, + handleSelectSwitcherTab, + createTerminalDisabled, + handleCreateDraftTab, + handleCreateTerminal, + handleCopyResumeCommand, + handleCopyAgentId, + handleCopyTerminalId, + handleCopyFilePath, + handleReloadAgent, + handleRenameTab, + handleCloseTabById, + ], + ); + const renderCompactWorkspaceHeader = useCallback( + ( + headerTabs: WorkspaceTabDescriptor[], + headerActiveTabKey: string, + actions: WorkspaceCompactHeaderActions, + actionsOnly = false, + ) => { + const tabSwitcher = renderWorkspaceTabSwitcher(headerTabs, headerActiveTabKey, actions); + return ( - - ) : null, + ); + }, [ - activeTabKey, + renderWorkspaceTabSwitcher, + isFocusModeEnabled, + toggleFocusMode, canOpenImportSheet, compactHeaderScripts, createTerminalDisabled, currentBranchName, explorerSidebarToggleAccessibilityState, explorerSidebarToggleLabel, - handleCloseOtherTabs, - handleCloseTabById, - handleCloseTabsToLeft, - handleCloseTabsToRight, - handleCopyAgentId, handleCopyBranchName, - handleCopyFilePath, - handleCopyResumeCommand, - handleCopyTerminalId, handleCopyWorkspacePath, handleCreateBrowserTab, - handleCreateDraftTab, - handleCreateTerminal, handleCreateTerminalWithProfile, handleOpenSetupTab, - handleReloadAgent, - handleRenameTab, - handleSelectSwitcherTab, handleToggleExplorerSidebar, isMobile, + isRouteFocused, isWorkspaceHeaderLoading, isWorkspaceHeaderSubtitleDistinct, normalizedServerId, @@ -3448,12 +3492,27 @@ function WorkspaceScreenContent({ openImportSheet, showCreateBrowserTab, showWorkspaceSetup, - tabs, workspaceDirectory, workspaceHeaderSubtitle, workspaceHeaderTitle, + workspacePanelActive, ], ); + const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, { + closeAbove: handleCloseTabsToLeft, + closeBelow: handleCloseTabsToRight, + closeOthers: handleCloseOtherTabs, + }); + const fullWorkspaceActions = renderCompactWorkspaceHeader( + tabs, + activeTabKey, + { + closeAbove: handleCloseTabsToLeft, + closeBelow: handleCloseTabsToRight, + closeOthers: handleCloseOtherTabs, + }, + true, + ); const desktopSplitContent = useMemo(() => { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { return null; @@ -3462,6 +3521,7 @@ function WorkspaceScreenContent({ handleCreateNewTab({ paneId: focusedPaneIdOrUndefined }), + [handleCreateNewTab, focusedPaneIdOrUndefined], + ); + const ownsFallbackKeyboardShortcuts = useMemo( + () => shouldRenderDesktopPaneFallback && isRouteFocused, + [shouldRenderDesktopPaneFallback, isRouteFocused], + ); + useWorkspaceNewTabKeyboardAction({ + handlerName: "workspace-chrome-new-tab", + ownsKeyboardShortcuts: ownsFallbackKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId: focusedPaneIdOrUndefined, + createNewTab: createFallbackTab, + }); const compactHeaderLayout = resolveCompactHeaderLayout({ header: compactWorkspaceHeader, activeTab: activeTabDescriptor, @@ -3559,36 +3636,41 @@ function WorkspaceScreenContent({ ) : ( - {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} - - {shouldRenderDesktopPaneFallback ? ( - - - - ) : null} + {rendersDesktopSplitContent ? null : ( + + {fullWorkspaceActions} + {renderWorkspaceScreenHeader()} + {shouldRenderDesktopPaneFallback ? ( + + + + ) : null} + + )} {desktopContent} diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 7c7b98cee0..9b428bc0bc 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -1,3 +1,4 @@ +import { buildDesktopFrameStyle } from "@/components/ui/combobox-frame-style"; import { WorkspaceHeaderExplorerToggle, WorkspaceExplorerSidebarToggle, @@ -7,6 +8,8 @@ import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { resolveWorkspaceTabChipBackdrop } from "@/screens/workspace/workspace-tab-chip-backdrop"; import { darkPureBlackTheme, darkTheme, lightTheme } from "@/styles/theme"; import { + workspaceTabSwitcherPopoverLayout, + selectWorkspaceChrome, computeWorkspaceTabLayout, retainWorkspaceTabMeasuredWidth, } from "@/screens/workspace/workspace-tab-layout"; @@ -256,3 +259,37 @@ describe("resolveWorkspaceTabChipBackdrop", () => { ).toBe("surface0"); }); }); + +import { shouldUseCompactWorkspaceHeader } from "./workspace-tab-layout"; +it.each([ + [349, true], + [499, true], + [500, false], + [800, false], +])("workspace width %s selects compact=%s", (width, compact) => { + expect(shouldUseCompactWorkspaceHeader(width)).toBe(compact); +}); + +describe("desktop pane chrome", () => { + it("replaces the pane header and rail together at the pane width boundary", () => { + const full = ["workspace header", "pane tab rail"]; + expect(selectWorkspaceChrome({ width: 440, compact: "pane dropdown", full })).toBe( + "pane dropdown", + ); + expect(selectWorkspaceChrome({ width: 500, compact: "pane dropdown", full })).toBe(full); + expect(selectWorkspaceChrome({ width: null, compact: "pane dropdown", full })).toBe(full); + }); +}); + +it("gives the tab switcher a readable popover width independent of its 16pt count badge", () => { + const frame = buildDesktopFrameStyle({ + desktopMinWidth: workspaceTabSwitcherPopoverLayout.desktopMinWidth, + desktopLockWidth: false, + referenceWidth: 16, + desktopFixedHeight: undefined, + desktopPositionStyle: {}, + shouldHideDesktopContent: false, + availableHeight: 600, + }) as Array<{ minWidth?: number }>; + expect(frame[0].minWidth).toBeGreaterThanOrEqual(320); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index cb89d9c54c..2a9ec47837 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -1,3 +1,9 @@ +import { COMPACT_CONTENT_WIDTH } from "@/constants/layout"; + +export function shouldUseCompactWorkspaceHeader(width: number): boolean { + return width < COMPACT_CONTENT_WIDTH; +} + export type WorkspaceTabCloseButtonPolicy = "all" | "none"; export interface WorkspaceTabLayoutMetrics { @@ -120,3 +126,16 @@ export function computeWorkspaceTabLayout( requiresHorizontalScrollFallback, }; } + +export function selectWorkspaceChrome(input: { + width: number | null; + compact: Compact; + full: Full; +}): Compact | Full { + const compact = input.width !== null && shouldUseCompactWorkspaceHeader(input.width); + return compact ? input.compact : input.full; +} + +// A count badge is only 16pt wide. Native popovers need a content width floor +// because their scrolling tab list does not contribute an intrinsic width. +export const workspaceTabSwitcherPopoverLayout = { desktopMinWidth: 320 } as const; diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 16f3a23f0b..def8c73cc4 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -38,9 +38,7 @@ function navigateDeps(): NavigateToWorkspaceDeps { return { revealTab: revealWorkspaceTab, revealWorkspace: () => { - if (useWorkspaceLayoutStore.getState().layoutMode === "compact") { - usePanelStore.getState().showMobileAgent(); - } + usePanelStore.getState().showMobileAgent(); }, getSessionWorkspaces: (serverId) => useSessionStore.getState().sessions[serverId]?.workspaces, getSessionAgents: (serverId) => diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index 8af964f181..660f2d1b62 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -5,6 +5,7 @@ import { clampTreeRailWidth, clampSidebarWidth, DEFAULT_TREE_RAIL_WIDTH, + dropOverlayAgentList, DEFAULT_SIDEBAR_WIDTH, MAX_TREE_RAIL_WIDTH, MAX_SIDEBAR_WIDTH, @@ -15,9 +16,11 @@ import { selectIsAgentListOpen, selectIsCompactFileExplorerOpen, setMobilePanelTarget, + reconcileMobilePanel, type DesktopSidebarState, type MobilePanelView, type MobilePanelSelection, + type PanelLayout, type PanelLayoutInput, type SortOption, } from "./state"; @@ -25,8 +28,10 @@ import { isWeb } from "@/constants/platform"; import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DesktopSidebarState, + LeftSidebarPresentation, MobilePanelView, MobilePanelSelection, + PanelLayout, PanelLayoutInput, SortOption, } from "./state"; @@ -80,6 +85,7 @@ export interface PanelState { openAgentListForLayout: (input: PanelLayoutInput) => void; closeAgentListForLayout: (input: PanelLayoutInput) => void; toggleAgentListForLayout: (input: PanelLayoutInput) => void; + applyPanelLayout: (layout: PanelLayout) => void; // File explorer settings actions setExpandedPathsForWorkspace: (workspaceKey: string, paths: ExpandedPathsUpdate) => void; @@ -102,6 +108,13 @@ function setMobilePanelTargetPatch( return mobilePanel === state.mobilePanel ? state : { mobilePanel }; } +function dockAgentListPatch(state: PanelState): Pick { + return { + desktop: { ...state.desktop, agentListOpen: true }, + mobilePanel: dropOverlayAgentList(state.mobilePanel), + }; +} + export const usePanelStore = create()( persist( (set) => ({ @@ -151,12 +164,7 @@ export const usePanelStore = create()( ), openDesktopAgentList: () => - set((state) => { - if (state.desktop.agentListOpen) { - return state; - } - return { desktop: { ...state.desktop, agentListOpen: true } }; - }), + set((state) => (state.desktop.agentListOpen ? state : dockAgentListPatch(state))), closeDesktopAgentList: () => set((state) => { @@ -167,18 +175,18 @@ export const usePanelStore = create()( }), toggleDesktopAgentList: () => - set((state) => ({ - desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen }, - })), + set((state) => + state.desktop.agentListOpen + ? { desktop: { ...state.desktop, agentListOpen: false } } + : dockAgentListPatch(state), + ), openAgentListForLayout: ({ isCompact }) => set((state) => { if (isCompact) { return setMobilePanelTargetPatch(state, "agent-list"); } - return state.desktop.agentListOpen - ? state - : { desktop: { ...state.desktop, agentListOpen: true } }; + return state.desktop.agentListOpen ? state : dockAgentListPatch(state); }), closeAgentListForLayout: ({ isCompact }) => @@ -199,9 +207,15 @@ export const usePanelStore = create()( state.mobilePanel.target === "agent-list" ? "agent" : "agent-list", ); } - return { - desktop: { ...state.desktop, agentListOpen: !state.desktop.agentListOpen }, - }; + return state.desktop.agentListOpen + ? { desktop: { ...state.desktop, agentListOpen: false } } + : dockAgentListPatch(state); + }), + + applyPanelLayout: (layout) => + set((state) => { + const mobilePanel = reconcileMobilePanel(state.mobilePanel, layout); + return mobilePanel === state.mobilePanel ? state : { mobilePanel }; }), setExpandedPathsForWorkspace: (workspaceKey, paths) => diff --git a/packages/app/src/stores/panel-store/state.test.ts b/packages/app/src/stores/panel-store/state.test.ts index ad52b8a8c4..317d4fd9a8 100644 --- a/packages/app/src/stores/panel-store/state.test.ts +++ b/packages/app/src/stores/panel-store/state.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { subscribeMobilePanelSelection } from "@/mobile-panels/selection"; +import { beforeEach, describe, expect, it } from "vitest"; +import { usePanelStore } from "."; import { DEFAULT_TREE_RAIL_WIDTH, migratePanelState, @@ -122,3 +124,101 @@ it("discards obsolete checkout selection while preserving other panel preference expect(state).not.toHaveProperty("explorerTabByCheckout"); expect(state.sidebarWidth).toBe(400); }); + +describe("panel-store wide navigation overlay", () => { + it("subscribes with the current selection and observes future commands until unsubscribe", () => { + usePanelStore.getState().showMobileAgent(); + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "hidden" }); + const published = usePanelStore.getState().mobilePanel; + const received: (typeof published)[] = []; + const unsubscribe = subscribeMobilePanelSelection((selection) => received.push(selection)); + try { + expect(received).toEqual([published]); + usePanelStore.getState().showMobileAgentList(); + expect(received).toEqual([published, usePanelStore.getState().mobilePanel]); + unsubscribe(); + const before = [...received]; + usePanelStore.getState().showMobileAgent(); + expect(received).toEqual(before); + } finally { + unsubscribe(); + } + }); + + beforeEach(() => { + const panels = usePanelStore.getState(); + panels.showMobileAgent(); + panels.closeDesktopAgentList(); + }); + + it.each([ + ["openDesktopAgentList", () => usePanelStore.getState().openDesktopAgentList()], + ["toggleDesktopAgentList", () => usePanelStore.getState().toggleDesktopAgentList()], + [ + "openAgentListForLayout", + () => usePanelStore.getState().openAgentListForLayout({ isCompact: false }), + ], + [ + "toggleAgentListForLayout", + () => usePanelStore.getState().toggleAgentListForLayout({ isCompact: false }), + ], + ])("drops the overlay target when %s docks the sidebar", (_name, dock) => { + usePanelStore.getState().showMobileAgentList(); + + dock(); + + expect(usePanelStore.getState().desktop.agentListOpen).toBe(true); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + + it("drops the overlay target once the sidebar is no longer presented as an overlay", () => { + usePanelStore.getState().showMobileAgentList(); + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "overlay" }); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent-list"); + + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "dock" }); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + + usePanelStore.getState().showMobileAgentList(); + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "hidden" }); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + }); + + it.each(["dock", "hidden"] as const)( + "invalidates an opening swipe when presentation becomes %s before its target commits", + (presentation) => { + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: "overlay" }); + const startedRevision = usePanelStore.getState().mobilePanel.revision; + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar: presentation }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + expect(usePanelStore.getState().mobilePanel.revision).toBeGreaterThan(startedRevision); + }, + ); + + it.each(["overlay", "hidden"] as const)( + "keeps the compact explorer target when the compact sidebar becomes %s", + (leftSidebar) => { + usePanelStore.getState().showMobileFileExplorer(); + + usePanelStore.getState().applyPanelLayout({ isCompact: true, leftSidebar }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("file-explorer"); + }, + ); + + it.each(["overlay", "dock", "hidden"] as const)( + "drops the compact explorer target when the layout widens with a %s sidebar", + (leftSidebar) => { + usePanelStore.getState().showMobileFileExplorer(); + const openedRevision = usePanelStore.getState().mobilePanel.revision; + + usePanelStore.getState().applyPanelLayout({ isCompact: false, leftSidebar }); + + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + expect(usePanelStore.getState().mobilePanel.revision).toBeGreaterThan(openedRevision); + }, + ); +}); diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index cbbead4161..a9971fa458 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -26,6 +26,14 @@ export interface PanelLayoutInput { isCompact: boolean; } +/** How app chrome presents the left sidebar: over the workspace, beside it, or not at all. */ +export type LeftSidebarPresentation = "overlay" | "dock" | "hidden"; + +/** The panels app chrome presents, which decide the mobile panel targets that can stay selected. */ +export interface PanelLayout extends PanelLayoutInput { + leftSidebar: LeftSidebarPresentation; +} + export interface PanelCoreState { mobilePanel: MobilePanelSelection; desktop: DesktopSidebarState; @@ -68,6 +76,35 @@ export function setMobilePanelTarget( return { target, revision: selection.revision + 1 }; } +/** + * Only an overlay sidebar shows the `agent-list` target. A docked or hidden sidebar drops it, so + * a stale target cannot resurface the overlay on the next collapse. Always invalidate in-flight + * gestures, even when an opening swipe has not committed `agent-list` yet. + */ +export function dropOverlayAgentList(selection: MobilePanelSelection): MobilePanelSelection { + return { + target: selection.target === "agent-list" ? "agent" : selection.target, + revision: selection.revision + 1, + }; +} + +/** + * Keeps only the targets the layout presents. The `file-explorer` target exists on compact layouts + * only; a wide layout docks the Explorer instead, and a stale target there would block the + * navigation swipe, which begins only from `agent`. + */ +export function reconcileMobilePanel( + selection: MobilePanelSelection, + layout: PanelLayout, +): MobilePanelSelection { + const sidebarReconciled = + layout.leftSidebar === "overlay" ? selection : dropOverlayAgentList(selection); + if (!layout.isCompact && sidebarReconciled.target === "file-explorer") { + return setMobilePanelTarget(sidebarReconciled, "agent"); + } + return sidebarReconciled; +} + const ExplorerTabSchema = z.enum(["changes", "files", "pr"]); const DesktopSidebarStorageSchema = z.strictObject({ agentListOpen: z.boolean().optional(), diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index fb38605220..1b9052c4c1 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -557,6 +557,33 @@ describe("normalizeWorkspaceDescriptor", () => { expect(blank.workspaceDirectory).toBe(""); }); + it("preserves informational workspace parentage", () => { + const payload = { + id: "child", + parentWorkspaceId: "parent", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + } as WorkspaceDescriptorPayload; + + const workspace = normalizeWorkspaceDescriptor(payload); + + expect(workspace.parentWorkspaceId).toBe("parent"); + expect( + normalizeWorkspaceDescriptor({ ...payload, parentWorkspaceId: undefined }).parentWorkspaceId, + ).toBeUndefined(); + }); + it("defaults missing scripts to an empty array", () => { const payload = { id: "1", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index a94bdf1053..f766d264af 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -109,6 +109,7 @@ export interface Agent { } export interface WorkspaceDescriptor { + parentWorkspaceId?: string; createdAt: Date | null; lastUserMessageAt: Date | null; id: string; @@ -166,6 +167,7 @@ export function normalizeWorkspaceDescriptor( projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, background: payload.background ?? false, + parentWorkspaceId: payload.parentWorkspaceId, name: payload.name, title: payload.title ?? null, pinnedAt: payload.pinnedAt ?? null, diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts index d29547ee88..1e66a555c0 100644 --- a/packages/app/src/styles/density.ts +++ b/packages/app/src/styles/density.ts @@ -1,33 +1,36 @@ +import { + bySizing, + iconSizeFor, + TOOLBAR_CONTROL_HEIGHT, + TOUCH_TARGET, + type IconSize, +} from "@/components/ui/control-geometry"; import { isCompactBreakpoint } from "@/constants/layout"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import type { Theme } from "@/styles/theme"; /** - * Density sizes for surfaces that opt into touch sizing: rows and icons grow on compact - * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay - * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. + * Density sizes for surfaces that opt into touch sizing: rows and icons take their touch sizes + * where controls do (see `resolveControlSizing`) and their pointer sizes elsewhere. Plain theme + * tokens stay pointer-sized everywhere, so a surface migrates by swapping its sizes for these. * * Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes * are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping. */ -export type DensityIconSize = keyof typeof ICON_SIZE; - -const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; - -// Compact layouts end where `md` begins; see `isCompactBreakpoint`. -function byLayout(compact: T, desktop: T) { - return { xs: compact, md: desktop }; -} +export type DensityIconSize = IconSize; export const density = { /** A list row such as a sidebar entry. */ - rowHeight: byLayout(44, 36), - /** A row in a tight group, such as the sidebar's navigation rows. */ - tightRowHeight: byLayout(36, 28), + rowHeight: bySizing({ touch: TOUCH_TARGET, pointer: 36 }), + /** A row in a tight group, such as the sidebar's navigation rows. Touch-sized it is a full row. */ + tightRowHeight: bySizing({ touch: TOUCH_TARGET, pointer: 28 }), /** A square icon-only button that sits in a row or toolbar. */ - iconButton: byLayout(44, 28), - /** The space between two blocks of rich text, such as Markdown paragraphs, code and images. */ - blockGap: byLayout(12, 16), + iconButton: TOOLBAR_CONTROL_HEIGHT, + /** + * The space between two blocks of rich text, such as Markdown paragraphs, code and images. + * Reading rhythm, not a touch target, so it follows the layout alone. + */ + blockGap: { xs: 12, md: 16 }, }; /** Resolves a density value for a prop, which Unistyles cannot resolve on its own. */ @@ -37,7 +40,7 @@ export function resolveDensity(rt: { breakpoint?: string }, value: { xs: T; m /** The width or height of a slot that holds an icon of `size`. */ export function densityIconBox(size: DensityIconSize) { - return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); + return bySizing(iconSizeFor(size)); } export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 0b72ccfcf5..cd8ee753fb 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -10,3 +10,4 @@ export { type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; export { useSubagentSheetTab } from "./sheet-tab"; +export { deriveSubagentStatusBucket } from "./track-presentation"; diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d95082ca39..5a1d671339 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -1,7 +1,8 @@ import type { TFunction } from "i18next"; import type { ComposerTrackPillSegment } from "@/composer/tracks"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { deriveSidebarStateBucket, STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; +import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; +import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; @@ -14,6 +15,16 @@ function presentationStatus(row: SubagentRow) { return providerSubagentLifecycleStatus(row.status); } +/** + * A child's mark reports what it is doing, not whether anyone read it: the track is where a + * failed child stays visible after the parent's own attention is cleared. + */ +export function deriveSubagentStatusBucket(status: AgentLifecycleStatus): SidebarStateBucket { + if (status === "error") return "failed"; + if (status === "running") return "running"; + return "done"; +} + export interface SubagentRowPresentationData { key: string; kind: "agent"; @@ -38,10 +49,7 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP label: label ?? "", subtitle: subtitle ?? "", titleState: label ? "ready" : "loading", - statusBucket: deriveSidebarStateBucket({ - status, - requiresAttention: false, - }), + statusBucket: deriveSubagentStatusBucket(status), }; } diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index b1cab84d0e..ae74a4945f 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -123,7 +123,6 @@ interface TerminalEmulatorRuntimeDisposables { removeVisualViewportResize: () => void; clearFitTimeouts: () => void; removeFontListeners: () => void; - removeTouchListeners: () => void; removeFilePasteListener: () => void; restoreDocumentStyles: () => void; restoreViewportStyles: () => void; @@ -155,7 +154,6 @@ const isAppleHandheld = maxTouchPoints: navigator.maxTouchPoints, }); -const DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX = 18; const FIT_TIMEOUT_DELAYS_MS = [0, 16, 48, 120, 250, 500, 1_000, 2_000]; const OUTPUT_OPERATION_TIMEOUT_MS = 5_000; const EMPTY_TERMINAL_OUTPUT = new Uint8Array(0); @@ -764,11 +762,6 @@ export class TerminalEmulatorRuntime { }; input.host.addEventListener("paste", filePasteHandler, true); - const removeTouchListeners = this.setupTouchScrollHandlers({ - root: input.root, - host: input.host, - terminal, - }); const resizeObserver = new ResizeObserver(() => { fitAndEmitResize({ shouldClaim: false }); }); @@ -848,7 +841,6 @@ export class TerminalEmulatorRuntime { removeFontListeners: () => { fontSet?.removeEventListener?.("loadingdone", fontReadyHandler); }, - removeTouchListeners, removeFilePasteListener: () => { input.host.removeEventListener("paste", filePasteHandler, true); }, @@ -880,7 +872,6 @@ export class TerminalEmulatorRuntime { disposables.removeVisualViewportResize(); disposables.clearFitTimeouts(); disposables.removeFontListeners(); - disposables.removeTouchListeners(); disposables.removeFilePasteListener(); disposables.disposeFitAddon(); disposables.disposeWebglAddon(); @@ -1420,120 +1411,4 @@ export class TerminalEmulatorRuntime { } }; } - - private setupTouchScrollHandlers(input: { - root: HTMLDivElement; - host: HTMLDivElement; - terminal: Terminal; - }): () => void { - let touchScrollRemainderPx = 0; - const measuredLineHeight = - input.host.querySelector(".xterm-rows > div")?.getBoundingClientRect().height ?? - 0; - const touchScrollLineHeightPx = - measuredLineHeight > 0 ? measuredLineHeight : DEFAULT_TOUCH_SCROLL_LINE_HEIGHT_PX; - - const activeTouch = { - identifier: -1, - startX: 0, - startY: 0, - lastX: 0, - lastY: 0, - mode: null as "vertical" | "horizontal" | null, - }; - - const touchStartHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - return; - } - - const touch = event.touches[0]; - if (!touch) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - return; - } - - activeTouch.identifier = touch.identifier; - activeTouch.startX = touch.clientX; - activeTouch.startY = touch.clientY; - activeTouch.lastX = touch.clientX; - activeTouch.lastY = touch.clientY; - activeTouch.mode = null; - touchScrollRemainderPx = 0; - }; - - const touchMoveHandler = (event: TouchEvent) => { - if (event.touches.length !== 1) { - return; - } - - const touch = Array.from(event.touches).find( - (candidate) => candidate.identifier === activeTouch.identifier, - ); - if (!touch) { - return; - } - - const totalDeltaX = touch.clientX - activeTouch.startX; - const totalDeltaY = touch.clientY - activeTouch.startY; - if (activeTouch.mode === null) { - const absX = Math.abs(totalDeltaX); - const absY = Math.abs(totalDeltaY); - if (absX > 8 || absY > 8) { - activeTouch.mode = absY >= absX ? "vertical" : "horizontal"; - } - } - - const deltaY = touch.clientY - activeTouch.lastY; - activeTouch.lastX = touch.clientX; - activeTouch.lastY = touch.clientY; - - if (activeTouch.mode !== "vertical") { - return; - } - - touchScrollRemainderPx += deltaY; - const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx); - if (lineDelta !== 0) { - input.terminal.scrollLines(-lineDelta); - touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; - } - - event.preventDefault(); - }; - - const touchEndHandler = (event: TouchEvent) => { - const activeTouchEnded = Array.from(event.changedTouches).some( - (touch) => touch.identifier === activeTouch.identifier, - ); - if (activeTouchEnded || event.touches.length === 0) { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - } - }; - - const touchCancelHandler = () => { - touchScrollRemainderPx = 0; - activeTouch.identifier = -1; - activeTouch.mode = null; - }; - - input.root.addEventListener("touchstart", touchStartHandler, { passive: true }); - input.root.addEventListener("touchmove", touchMoveHandler, { passive: false }); - input.root.addEventListener("touchend", touchEndHandler, { passive: true }); - input.root.addEventListener("touchcancel", touchCancelHandler, { passive: true }); - - return () => { - input.root.removeEventListener("touchstart", touchStartHandler); - input.root.removeEventListener("touchmove", touchMoveHandler); - input.root.removeEventListener("touchend", touchEndHandler); - input.root.removeEventListener("touchcancel", touchCancelHandler); - }; - } } diff --git a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts index 62a65c6878..5b1e875b44 100644 --- a/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-glyph-rendering.browser.test.ts @@ -160,6 +160,8 @@ it("preserves another terminal's glyph pixels when a shared atlas is cleared", a expectSamePixels({ expected: before, actual: after, label: "sibling after atlas clear" }); }); +// Software WebGL can take over 30 seconds for this corpus on a contended runner. +// Each capture still has its own two-second render deadline. it("preserves both headers while ordinary output grows the shared glyph atlas", async () => { const { active, sibling } = await mountPair(); const cells = active.terminal.cols * (active.terminal.rows - 2); @@ -199,4 +201,4 @@ it("preserves both headers while ordinary output grows the shared glyph atlas", label: `sibling frame ${batch}`, }); } -}, 30000); +}, 60_000); diff --git a/packages/app/src/tool-calls/detail-level/overview/view.tsx b/packages/app/src/tool-calls/detail-level/overview/view.tsx index b11e82e4f7..b05fbe1edd 100644 --- a/packages/app/src/tool-calls/detail-level/overview/view.tsx +++ b/packages/app/src/tool-calls/detail-level/overview/view.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useRef, type ReactNode } from "react"; import { ScrollView } from "react-native"; import { Wrench } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useDisclosureHold } from "@/agent-stream/disclosure"; import { IndentRail } from "@/components/indent-rail"; import { ExpandableBadge } from "@/components/message"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -32,9 +33,13 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView const scrollToLatest = useCallback(() => { scrollRef.current?.scrollToEnd({ animated: false }); }, []); + const hold = useDisclosureHold(); const toggle = useCallback(() => { - onExpandedChange(group.run.id, !expanded); - }, [expanded, group.run.id, onExpandedChange]); + const apply = () => onExpandedChange(group.run.id, !expanded); + // Compact opens a sheet; nothing in the timeline moves. + if (isCompact) apply(); + else hold(apply); + }, [expanded, group.run.id, hold, isCompact, onExpandedChange]); const close = useCallback(() => { onExpandedChange(group.run.id, false); }, [group.run.id, onExpandedChange]); diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 524972a010..bac8bd7ffa 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -33,9 +33,10 @@ import { } from "./model"; import type { UsageReportEntry, UsageView } from "./types"; -// The daemon caches each report for five minutes, so re-reading it is cheap. Only -// an explicit refresh passes `forceRefresh` and reaches the source's API. +// The daemon caches each report for five minutes, so re-reading it is cheap. +// An explicit refresh passes `forceRefresh` to bypass that cache. const REPORTS_STALE_TIME_MS = 60_000; +const REPORTS_REFETCH_INTERVAL_MS = 5 * 60_000; /** Every report list of a host: its own and each agent's. */ function hostUsageQueryKey(serverId: string) { @@ -164,11 +165,16 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const NO_REPORTS: UsageReportEntry[] = []; -/** - * The reports of the sidebar's usage host, which is connected and reports usage; none until they - * load, or without a host. - */ -export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { +interface UsageHostReportsInput { + serverId: string | null; + shouldPoll?: boolean; +} + +/** A host's reports, with periodic refresh enabled only by the visible sidebar summary. */ +export function useUsageHostReports({ + serverId, + shouldPoll = false, +}: UsageHostReportsInput): UsageReportEntry[] { const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const query = useFetchQuery({ @@ -177,6 +183,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, + refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false, }); return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS; } diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 437c0a929c..bd4dfeb65e 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -16,6 +16,8 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useAppVisible } from "@/hooks/use-app-visible"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useUsageDisplay } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; @@ -36,9 +38,14 @@ import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ export function useUsageSummary(): readonly PinnedUsageSource[] { const { t } = useTranslation(); + const active = useRetainedPanelActive(); + const isAppVisible = useAppVisible(); const { inSidebar } = useUsageInSidebar(); const serverId = useUsageHostId(); - const reports = useUsageHostReports(inSidebar ? serverId : null); + const reports = useUsageHostReports({ + serverId: inSidebar ? serverId : null, + shouldPoll: active && inSidebar && isAppVisible, + }); const { preferences } = useUsageDisplay(reports); return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } diff --git a/packages/app/src/utils/desktop-window.test.ts b/packages/app/src/utils/desktop-window.test.ts index aa95a90dbf..21f35537dc 100644 --- a/packages/app/src/utils/desktop-window.test.ts +++ b/packages/app/src/utils/desktop-window.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { intersectWindowChromeCorners, + resolveChildWindowChromeCorners, resolveHasOwnedWindowChromeObstruction, resolveWindowChromeObstruction, resolveWindowChromeSafeArea, @@ -87,3 +88,54 @@ describe("window chrome", () => { ).toBe(false); }); }); + +it("assigns window buttons only to exposed split edges", () => { + expect( + resolveChildWindowChromeCorners({ + corners: "both", + direction: "horizontal", + isFirst: true, + isLast: false, + }), + ).toBe("top-left"); + expect( + resolveChildWindowChromeCorners({ + corners: "both", + direction: "horizontal", + isFirst: false, + isLast: true, + }), + ).toBe("top-right"); + expect( + resolveChildWindowChromeCorners({ + corners: "both", + direction: "horizontal", + isFirst: false, + isLast: false, + }), + ).toBe("none"); + expect( + resolveChildWindowChromeCorners({ + corners: "both", + direction: "vertical", + isFirst: true, + isLast: false, + }), + ).toBe("both"); + expect( + resolveChildWindowChromeCorners({ + corners: "both", + direction: "vertical", + isFirst: false, + isLast: true, + }), + ).toBe("none"); + expect( + resolveChildWindowChromeCorners({ + corners: "top-right", + direction: "horizontal", + isFirst: true, + isLast: true, + }), + ).toBe("top-right"); +}); diff --git a/packages/app/src/utils/desktop-window.tsx b/packages/app/src/utils/desktop-window.tsx index adc3f7c9a1..fa1f397e1c 100644 --- a/packages/app/src/utils/desktop-window.tsx +++ b/packages/app/src/utils/desktop-window.tsx @@ -83,6 +83,19 @@ export function intersectWindowChromeCorners( ); } +export function resolveChildWindowChromeCorners(input: { + corners: WindowChromeCorners; + direction: "horizontal" | "vertical"; + isFirst: boolean; + isLast: boolean; +}): WindowChromeCorners { + if (input.direction === "vertical") return input.isFirst ? input.corners : "none"; + return intersectWindowChromeCorners( + input.corners, + windowChromeCornersFromFlags(input.isFirst, input.isLast), + ); +} + export function removeWindowChromeCorner( corners: WindowChromeCorners, corner: WindowChromeCorner, diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 66cfcd758c..a539a3881a 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -93,6 +93,8 @@ describe("workspace agent activity index", () => { workspaceId: "workspace-error", status: "error", turn: { phase: "idle", cancellationRequestId: null }, + requiresAttention: true, + attentionReason: "error", updatedAt: "2026-01-01T00:00:00.000Z", }), ], diff --git a/packages/app/src/window-layout/actions.browser.test.tsx b/packages/app/src/window-layout/actions.browser.test.tsx new file mode 100644 index 0000000000..a7f3a4a75e --- /dev/null +++ b/packages/app/src/window-layout/actions.browser.test.tsx @@ -0,0 +1,95 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { PortalProvider } from "@gorhom/portal"; +import { afterEach, expect, it, vi } from "vitest"; +import { Text } from "react-native"; +import { page } from "vitest/browser"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import type { WindowLayout } from "./model"; +import { WindowLayoutContext } from "./context"; +import { WindowLayoutActions, WindowLayoutActionsHost } from "./actions"; +import { WindowLayoutDivisionGap } from "./surface"; +import { lightTheme } from "@/styles/theme"; + +vi.stubGlobal("React", React); +const layout = { sidebarWidth: 465, gap: 21, contentWidth: 381 }; +const geometry: WindowLayout = { + bounds: { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }, + verticalBarEdge: "trailing", + verticalBar: { x: 20, y: 20, width: 44, height: 400 }, + divisions: [], +}; +const container = document.createElement("div"); +document.body.appendChild(container); +const root = createRoot(container); +afterEach(() => { + act(() => root.render(null)); +}); + +it("paints the reserved division with the sidebar surface", () => { + act(() => + root.render(), + ); + const gap = container.firstElementChild as HTMLElement; + const expected = document.createElement("div"); + expected.style.backgroundColor = lightTheme.colors.surfaceSidebar; + expect(getComputedStyle(gap).backgroundColor).toBe(expected.style.backgroundColor); + expect(getComputedStyle(gap).borderRightWidth).toBe("1px"); + expect(gap.getBoundingClientRect().width).toBe(21); +}); + +const explorerState = { expanded: false }; +const noKeys: [] = []; +const noop = () => {}; + +it("preserves circular explorer chrome and closes a portaled menu when actions become inactive", async () => { + async function renderActions(active: boolean) { + await act(async () => + root.render( + + + + + + Explorer + + + + Menu + + + Bar menu item + + + + + , + ), + ); + } + await renderActions(true); + const explorer = container.querySelector('[aria-label="Explorer"]') as HTMLElement; + expect(getComputedStyle(explorer).borderRadius).toBe(`${lightTheme.borderRadius.full}px`); + await page.getByRole("button", { name: "Bar menu", exact: true }).click(); + await expect.element(page.getByRole("menuitem", { name: "Bar menu item" })).toBeVisible(); + await renderActions(false); + await expect.poll(() => document.querySelectorAll('[role="menuitem"]').length).toBe(0); + expect( + container.querySelector('[data-testid="workspace-vertical-actions"]')?.getBoundingClientRect() + .height, + ).toBe(0); +}); diff --git a/packages/app/src/window-layout/actions.tsx b/packages/app/src/window-layout/actions.tsx new file mode 100644 index 0000000000..06fcf57633 --- /dev/null +++ b/packages/app/src/window-layout/actions.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { Portal, PortalHost } from "@gorhom/portal"; +import { StyleSheet, View } from "react-native"; +import { RetainedPanelActivity } from "@/components/retained-panel"; +import { IconButtonChromeProvider } from "@/components/ui/icon-button-chrome"; +import { useWindowLayout } from "./context"; +import { resolveWorkspaceActionPlacement } from "./model"; + +const HOST = "window-layout-actions"; + +export function WindowLayoutActionsHost() { + return ; +} + +/** The native bar guide is in window coordinates, so the host lives at the window root. */ +export function WindowLayoutActions({ + children, + active, +}: { + children: ReactNode; + active: boolean; +}) { + const layout = useWindowLayout(); + if (resolveWorkspaceActionPlacement(layout) !== "vertical-bar" || !layout?.verticalBar) { + return null; + } + const bar = layout.verticalBar; + return ( + + + + {children} + + + + ); +} + +const styles = StyleSheet.create({ + hidden: { display: "none" }, + bar: { position: "absolute", alignItems: "center", gap: 8 }, +}); diff --git a/packages/app/src/window-layout/context.ts b/packages/app/src/window-layout/context.ts new file mode 100644 index 0000000000..e2d0246a92 --- /dev/null +++ b/packages/app/src/window-layout/context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; +import { useWindowDimensions } from "react-native"; +import type { WindowLayout } from "./model"; + +export const WindowLayoutContext = createContext(null); + +export function useWindowLayout(): WindowLayout | null { + const layout = useContext(WindowLayoutContext); + const { width, height } = useWindowDimensions(); + return layout && + Math.abs(layout.bounds.width - width) <= 0.5 && + Math.abs(layout.bounds.height - height) <= 0.5 + ? layout + : null; +} diff --git a/packages/app/src/window-layout/index.ios.tsx b/packages/app/src/window-layout/index.ios.tsx new file mode 100644 index 0000000000..507133528e --- /dev/null +++ b/packages/app/src/window-layout/index.ios.tsx @@ -0,0 +1,52 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native"; +import { requireNativeViewManager } from "expo-modules-core"; +import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; +import { WindowLayoutContext, useWindowLayout } from "./context"; +import { resolveSafeAreaInsets, type WindowLayout } from "./model"; + +export { useWindowLayout } from "./context"; +export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions"; +export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface"; +export { + resolveDesktopDivisionLayout, + resolveWorkspaceActionPlacement, + resolveWorkspaceBarActions, +} from "./model"; +export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model"; + +const GeometryProbe = requireNativeViewManager< + ViewProps & { + onGeometryChange: (event: NativeSyntheticEvent) => void; + } +>("PaseoWindowLayout"); + +export function WindowLayoutProvider({ children }: { children: ReactNode }) { + const [geometry, setGeometry] = useState(null); + const onGeometryChange = useCallback( + (event: NativeSyntheticEvent) => setGeometry(event.nativeEvent), + [], + ); + return ( + + + + {children} + + + ); +} + +/** The one bottom inset every descendant reads through `useSafeAreaInsets()`. */ +function WindowSafeAreaInsets({ children }: { children: ReactNode }) { + const systemInsets = useSafeAreaInsets(); + const layout = useWindowLayout(); + const insets = useMemo(() => resolveSafeAreaInsets(systemInsets, layout), [systemInsets, layout]); + return {children}; +} + +const styles = StyleSheet.create({ root: { flex: 1 } }); diff --git a/packages/app/src/window-layout/index.tsx b/packages/app/src/window-layout/index.tsx new file mode 100644 index 0000000000..c884d3e987 --- /dev/null +++ b/packages/app/src/window-layout/index.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; + +export { useWindowLayout } from "./context"; +export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions"; +export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface"; +export { + resolveDesktopDivisionLayout, + resolveWorkspaceActionPlacement, + resolveWorkspaceBarActions, +} from "./model"; +export type { DesktopDivisionLayout, WindowLayout, WindowLayoutRect } from "./model"; + +export function WindowLayoutProvider({ children }: { children: ReactNode }) { + return children; +} diff --git a/packages/app/src/window-layout/model.test.ts b/packages/app/src/window-layout/model.test.ts new file mode 100644 index 0000000000..e363d4313a --- /dev/null +++ b/packages/app/src/window-layout/model.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { resolveSafeAreaInsets, type WindowLayout } from "./model"; + +const systemInsets = { top: 59, right: 0, bottom: 34, left: 0 }; + +function layout(bottomClearance: number | null | undefined): WindowLayout { + return { + bounds: { x: 0, y: 0, width: 402, height: 874 }, + verticalBarEdge: "none", + verticalBar: null, + bottomClearance, + divisions: [], + }; +} + +describe("resolveSafeAreaInsets", () => { + it("replaces only the bottom with the measured bar clearance", () => { + expect(resolveSafeAreaInsets(systemInsets, layout(24))).toEqual({ + ...systemInsets, + bottom: 24, + }); + }); + + it("keeps a measured zero instead of falling back to the system inset", () => { + expect(resolveSafeAreaInsets(systemInsets, layout(0)).bottom).toBe(0); + }); + + it.each([null, undefined])("leaves system insets alone without a clearance (%s)", (value) => { + expect(resolveSafeAreaInsets(systemInsets, layout(value))).toBe(systemInsets); + }); + + it("leaves system insets alone before the native layout arrives", () => { + expect(resolveSafeAreaInsets(systemInsets, null)).toBe(systemInsets); + }); +}); diff --git a/packages/app/src/window-layout/model.ts b/packages/app/src/window-layout/model.ts new file mode 100644 index 0000000000..d2ba0de709 --- /dev/null +++ b/packages/app/src/window-layout/model.ts @@ -0,0 +1,81 @@ +export interface WindowLayoutRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface WindowLayout { + bounds: WindowLayoutRect; + verticalBarEdge: "none" | "leading" | "trailing"; + verticalBar: WindowLayoutRect | null; + /** + * Points from the window's bottom edge to where a bottom bar rests, or null where + * the system has no bottom bar guide. Zero is a measured value. + */ + bottomClearance?: number | null; + divisions: WindowLayoutRect[]; +} + +/** The window's measured bottom clearance replaces the rectangular safe-area bottom. */ +export function resolveSafeAreaInsets( + systemInsets: Insets, + layout: WindowLayout | null, +): Insets { + const clearance = layout?.bottomClearance; + return typeof clearance === "number" && clearance !== systemInsets.bottom + ? { ...systemInsets, bottom: clearance } + : systemInsets; +} + +export interface DesktopDivisionLayout { + sidebarWidth: number; + gap: number; + contentWidth: number; +} + +export function resolveWorkspaceActionPlacement( + layout: WindowLayout | null, +): "header" | "vertical-bar" { + return layout?.verticalBarEdge === "trailing" && layout.verticalBar !== null + ? "vertical-bar" + : "header"; +} + +export function resolveWorkspaceBarActions( + headerVariant: "compact" | "full", +): readonly ("tab-count" | "overflow" | "explorer")[] { + return headerVariant === "compact" + ? ["tab-count", "overflow", "explorer"] + : ["overflow", "explorer"]; +} + +/** One book-like partition owns allocation; horizontal folds retain the normal flow. */ +export function resolveDesktopDivisionLayout(input: { + geometry: WindowLayout | null; + windowWidth: number; + windowHeight: number; + leftInset: number; + rightInset: number; +}): DesktopDivisionLayout | null { + const { geometry, windowWidth, windowHeight, leftInset, rightInset } = input; + if ( + !geometry || + Math.abs(geometry.bounds.width - windowWidth) > 0.5 || + Math.abs(geometry.bounds.height - windowHeight) > 0.5 + ) + return null; + const divisions = geometry.divisions.filter( + (frame) => + frame.width > 0 && + frame.height > frame.width && + frame.y < windowHeight && + frame.y + frame.height > 0, + ); + if (divisions.length !== 1) return null; + const division = divisions[0]; + const sidebarWidth = division.x - leftInset; + const contentWidth = windowWidth - rightInset - division.x - division.width; + if (sidebarWidth <= 0 || contentWidth <= 0) return null; + return { sidebarWidth, gap: division.width, contentWidth }; +} diff --git a/packages/app/src/window-layout/surface.tsx b/packages/app/src/window-layout/surface.tsx new file mode 100644 index 0000000000..e9645c799e --- /dev/null +++ b/packages/app/src/window-layout/surface.tsx @@ -0,0 +1,64 @@ +import { useMemo, type ReactNode } from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context"; +import { useWindowLayout } from "./context"; +import { resolveWorkspaceActionPlacement, type DesktopDivisionLayout } from "./model"; + +/** Consume horizontal safe areas once, including compact content beside a vertical bar. */ +export function WindowLayoutSurface({ + children, + compact, + style, +}: { + children: ReactNode; + compact: boolean; + style: StyleProp; +}) { + const insets = useSafeAreaInsets(); + const usesBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar"; + const surfaceStyle = useMemo( + () => [ + style, + (!compact || usesBar) && { paddingLeft: insets.left, paddingRight: insets.right }, + ], + [style, compact, usesBar, insets.left, insets.right], + ); + const contentInsets = useMemo( + () => (compact && usesBar ? { ...insets, left: 0, right: 0 } : insets), + [compact, usesBar, insets], + ); + return ( + + + {children} + + + ); +} + +export function WindowLayoutDivisionGap({ + layout, + sidebarVisible, + compact, +}: { + layout: DesktopDivisionLayout | null; + sidebarVisible: boolean; + compact: boolean; +}) { + const style = useMemo( + () => ({ + width: layout ? layout.gap + (sidebarVisible ? 0 : layout.sidebarWidth) : 0, + }), + [layout, sidebarVisible], + ); + return !compact && layout ? : null; +} + +const styles = StyleSheet.create((theme) => ({ + divisionGap: { + backgroundColor: theme.colors.surfaceSidebar, + borderRightWidth: 1, + borderRightColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/workspace-tabs/navigation.test.ts b/packages/app/src/workspace-tabs/navigation.test.ts index c32fc91cf9..de918e53d4 100644 --- a/packages/app/src/workspace-tabs/navigation.test.ts +++ b/packages/app/src/workspace-tabs/navigation.test.ts @@ -33,6 +33,28 @@ it("reveals an existing main file selected from compact Explorer", () => { expect(existing).toBe(tabId); expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); }); +it.each(["single-pane", "split-panes"] as const)( + "dismisses floating navigation when revealing a %s tab without pinning its dock", + (layoutMode) => { + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode(layoutMode); + usePanelStore.getState().closeDesktopAgentList(); + usePanelStore.getState().showMobileAgentList(); + const tabId = store.openTab({ + workspaceKey, + target: { kind: "agent", agentId: "root" }, + intent: "reveal", + }); + revealWorkspaceTab(workspaceKey, tabId); + expect(usePanelStore.getState().mobilePanel.target).toBe("agent"); + expect(usePanelStore.getState().desktop.agentListOpen).toBe(false); + expect( + useWorkspaceLayoutStore.getState().getTabDestination(workspaceKey, tabId!), + ).toMatchObject({ + host: "main", + }); + }, +); it("keeps Explorer beneath a new compact file sheet", () => { const store = useWorkspaceLayoutStore.getState(); store.setLayoutMode("compact"); diff --git a/packages/app/src/workspace-tabs/navigation.ts b/packages/app/src/workspace-tabs/navigation.ts index 5e36f65102..89e46994b8 100644 --- a/packages/app/src/workspace-tabs/navigation.ts +++ b/packages/app/src/workspace-tabs/navigation.ts @@ -10,10 +10,11 @@ export function revealWorkspaceTab(workspaceKey: string, tabId: string | null): if (!destination) return null; if (destination.kind === "sheet") return tabId; store.focusTab(workspaceKey, tabId); - if (store.layoutMode === "compact") { - const panels = usePanelStore.getState(); - if (destination.host === "explorer") panels.showMobileFileExplorer(); - else panels.showMobileAgent(); + const panels = usePanelStore.getState(); + if (store.layoutMode === "compact" && destination.host === "explorer") { + panels.showMobileFileExplorer(); + } else { + panels.showMobileAgent(); } return tabId; } diff --git a/packages/app/src/workspace/open-in-editor/button.tsx b/packages/app/src/workspace/open-in-editor/button.tsx index e2b0530a82..5df614ec50 100644 --- a/packages/app/src/workspace/open-in-editor/button.tsx +++ b/packages/app/src/workspace/open-in-editor/button.tsx @@ -27,7 +27,7 @@ import { planWorkspaceOpenTargets } from "@/workspace/open-in-editor/planner"; import type { Theme } from "@/styles/theme"; import { ForgeBrandIcon } from "@/git/forge-icon"; import { getForgePresentation } from "@/git/forge"; -import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; +import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; import { extraMutedIconColorMapping } from "@/components/ui/icon-button-chrome"; interface WorkspaceOpenInEditorButtonProps { @@ -297,10 +297,7 @@ const styles = StyleSheet.create((theme) => ({ flexShrink: 0, }, splitButton: { - height: { - xs: CONTROL_HEIGHTS.sm, - md: HEADER_CONTROL_HEIGHT, - }, + height: HEADER_CONTROL_HEIGHT, flexDirection: "row", alignItems: "stretch", borderRadius: theme.borderRadius.md, @@ -317,10 +314,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, splitButtonPrimaryIconOnly: { - width: { - xs: CONTROL_HEIGHTS.sm, - md: HEADER_CONTROL_HEIGHT, - }, + width: HEADER_CONTROL_HEIGHT, paddingHorizontal: 0, justifyContent: "center", position: "relative", @@ -348,10 +342,7 @@ const styles = StyleSheet.create((theme) => ({ transform: [{ scale: 0.8 }], }, splitButtonCaret: { - width: { - xs: CONTROL_HEIGHTS.sm, - md: HEADER_CONTROL_HEIGHT, - }, + width: HEADER_CONTROL_HEIGHT, alignItems: "center", justifyContent: "center", borderLeftWidth: theme.borderWidth[1], diff --git a/packages/app/test-stubs/lucide-react-native.ts b/packages/app/test-stubs/lucide-react-native.ts index e03104d01b..af3130015d 100644 --- a/packages/app/test-stubs/lucide-react-native.ts +++ b/packages/app/test-stubs/lucide-react-native.ts @@ -11,6 +11,7 @@ const StubIcon: LucideIcon = () => null; export const Activity = StubIcon; export const AlertTriangle = StubIcon; export const Archive = StubIcon; +export const ArrowDown = StubIcon; export const ArrowDownUp = StubIcon; export const ArrowLeft = StubIcon; export const ArrowLeftToLine = StubIcon; diff --git a/packages/app/test-stubs/react-native-unistyles.ts b/packages/app/test-stubs/react-native-unistyles.ts index 5c26310291..04f61bc77e 100644 --- a/packages/app/test-stubs/react-native-unistyles.ts +++ b/packages/app/test-stubs/react-native-unistyles.ts @@ -1,4 +1,5 @@ import { lightHighlightColors } from "@getpaseo/highlight"; +import { lightTheme } from "../src/styles/theme"; const testTheme = { colorScheme: "light", @@ -26,6 +27,7 @@ const testTheme = { surface1: "#fafafa", surface2: "#f4f4f5", surface3: "#e4e4e7", + surfaceSidebar: lightTheme.colors.surfaceSidebar, border: "#e4e4e7", borderAccent: "#a1a1aa", palette: { diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index b1fbe98c84..5df660ba1d 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -39,10 +39,12 @@ export default defineConfig({ name: "compile-react-components", enforce: "pre", transform(source, id) { - // Exercise compiler-sensitive labels and overlay lifetime in the same form as Expo. + // Exercise compiler-sensitive labels, overlay lifetime and timeline row revisions in the same + // form as Expo. const usageComponent = id.includes("/src/usage/") && id.endsWith(".tsx"); const overlayLayer = id.endsWith("/src/lib/overlay-root.ts"); - if ((!usageComponent && !overlayLayer) || id.includes(".test.")) return; + const streamRow = id.endsWith("/src/agent-stream/stream-row.tsx"); + if ((!usageComponent && !overlayLayer && !streamRow) || id.includes(".test.")) return; const result = transformSync(source, { filename: id, babelrc: false, @@ -95,6 +97,17 @@ export default defineConfig({ }, { extends: true, + resolve: { + alias: [ + // The app renders LegendList on native only, from the patched native build. Browser + // tests render that build too, instead of the package's separate web build. Unit tests + // load it outside Vite, where its react-native import would miss the web alias. + { + find: /^@legendapp\/list\/react-native$/, + replacement: path.resolve(rootNodeModules, "@legendapp/list/react-native.mjs"), + }, + ], + }, test: { name: "browser", fileParallelism: false, @@ -165,6 +178,8 @@ export default defineConfig({ "expo-router/react-navigation", "@testing-library/react", "@tanstack/react-query", + "@tanstack/react-virtual", + "@legendapp/list/react-native", "react-i18next", "i18next", "@marijn/find-cluster-break", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6f3737d3cc..9248988869 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/cli", - "version": "0.15.0", + "version": "0.15.2", "description": "Paseo CLI - control your AI coding agents from the command line", "license": "Apache-2.0", "bin": { @@ -29,9 +29,9 @@ }, "dependencies": { "@clack/prompts": "^1.0.0", - "@getpaseo/client": "0.15.0", - "@getpaseo/protocol": "0.15.0", - "@getpaseo/server": "0.15.0", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/server": "0.15.2", "chalk": "^5.3.0", "commander": "^12.0.0", "mime-types": "^2.1.35", diff --git a/packages/client/package.json b/packages/client/package.json index ceeebba644..62e5b8c664 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/client", - "version": "0.15.0", + "version": "0.15.2", "description": "Paseo client SDK package", "license": "Apache-2.0", "files": [ @@ -48,8 +48,8 @@ "test": "vitest run" }, "dependencies": { - "@getpaseo/protocol": "0.15.0", - "@getpaseo/relay": "0.15.0", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/relay": "0.15.2", "ws": "^8.20.0", "zod": "^4.4.3" }, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index ebf99258dc..fa12b94048 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -657,6 +657,38 @@ class FakeDaemon { }, }; + finishTransfer(): void { + // Four MiB of JSON whitespace precedes this valid application message. + const bytes = new TextEncoder().encode( + JSON.stringify({ + type: "session", + message: { + type: "status", + payload: { + status: "server_info", + serverId: "srv_heartbeat_test", + hostname: null, + version: null, + features: { binaryChunkingV1: true }, + }, + }, + }), + ); + const frame = new Uint8Array(bytes.length + 4); + frame.set([0x31, 1, 0, 2]); + frame.set(bytes, 4); + this.onMessage(frame); + this.onMessage(JSON.stringify({ type: "pong" })); + this.daemonAnswersPingsAfter("fast"); + } + + receiveChunk(start: boolean): void { + const frame = new Uint8Array(1024 * 1024 + 4); + frame.set([0x31, 1, 0, start ? 1 : 0]); + frame.fill(32, 4); + this.onMessage(frame); + } + openConnection(): void { this.onOpen(); this.onMessage( @@ -667,6 +699,7 @@ class FakeDaemon { payload: { status: "server_info", serverId: "srv_heartbeat_test", + features: { binaryChunkingV1: true }, hostname: null, version: null, }, @@ -2572,7 +2605,7 @@ test("keeps default connect timeout shorter than session RPC waiters", async () } }); -test("foreground verification replaces a silently broken socket without waiting for heartbeats", async () => { +test("foreground checks a silently broken socket through two normal heartbeat misses", async () => { useHeartbeatClock(); const first = new FakeDaemon(); const second = new FakeDaemon(); @@ -2590,7 +2623,11 @@ test("foreground verification replaces a silently broken socket without waiting await connection; client.ensureConnected({ verify: true }); - await vi.advanceTimersByTimeAsync(3_000); + await vi.advanceTimersByTimeAsync(15_000); + expect(first.closesFromClient()).toEqual([]); + await vi.advanceTimersByTimeAsync(25_000); + expect(first.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); + client.ensureConnected(); expect(attempts).toBe(2); second.openConnection(); expect(client.getConnectionState()).toEqual({ status: "connected" }); @@ -2618,7 +2655,110 @@ test("foreground verification preserves a healthy socket and deduplicates simult expect(daemon.closesFromClient()).toEqual([]); }); -test("an obsolete foreground probe cannot close a replacement connection", async () => { +test("chunks prevent heartbeat misses until silence causes two misses", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonGoesSilent(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "bulk-resume", + logger: noopLogger, + reconnect: { enabled: false }, + transportFactory: () => daemon.transport, + }); + clients.push(client); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + let measurementError: unknown; + void client.measureLatency({ timeoutMs: 5_000 }).catch((error: unknown) => { + measurementError = error; + }); + await vi.advanceTimersByTimeAsync(5_000); + expect(measurementError).toEqual( + expect.objectContaining({ + message: "Latency measurement timed out (5000ms)", + }), + ); + for (let index = 0; index < 4; index++) { + await vi.advanceTimersByTimeAsync(index === 0 ? 5_000 : 10_000); + daemon.receiveChunk(index === 0); + expect(daemon.closesFromClient()).toEqual([]); + } + // No complete message or pong has arrived, but progress prevents misses. + expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]); + await vi.advanceTimersByTimeAsync(25_000); + expect(daemon.closesFromClient()).toEqual([]); + await vi.advanceTimersByTimeAsync(25_000); + expect(daemon.closesFromClient()).toEqual([{ code: 1001, reason: "Liveness check timed out" }]); +}); + +test("foreground preserves a multi-MB transfer with its pong queued behind chunks", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonGoesSilent(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "bulk-resume", + logger: noopLogger, + reconnect: { enabled: false }, + transportFactory: () => daemon.transport, + }); + clients.push(client); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + await vi.advanceTimersByTimeAsync(3_001); + expect(daemon.closesFromClient()).toEqual([]); + for (let index = 0; index < 4; index++) { + await vi.advanceTimersByTimeAsync(10_000); + daemon.receiveChunk(index === 0); + expect(daemon.closesFromClient()).toEqual([]); + } + // No complete message or pong has arrived, but progress prevents misses. + expect(daemon.pingTimestamps()).toEqual(["0s", "25s"]); + daemon.finishTransfer(); + await vi.advanceTimersByTimeAsync(30_000); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(daemon.closesFromClient()).toEqual([]); +}); + +test("chunk progress beyond the daemon lease keeps sending heartbeat pings", async () => { + useHeartbeatClock(); + const daemon = new FakeDaemon(); + daemon.daemonGoesSilent(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "long-download", + logger: noopLogger, + reconnect: { enabled: false }, + transportFactory: () => daemon.transport, + }); + clients.push(client); + const connection = client.connect(); + daemon.openConnection(); + await connection; + client.ensureConnected({ verify: true }); + for (let index = 0; index < 12; index++) { + await vi.advanceTimersByTimeAsync(5_000); + daemon.receiveChunk(index === 0); + // Each completed window starts the next ping ten seconds later, + // even though the response and all queued pongs are still in flight. + const expected = ["0s"]; + if (index >= 4) expected.push("25s"); + if (index >= 9) expected.push("50s"); + expect(daemon.pingTimestamps()).toEqual(expected); + expect(daemon.closesFromClient()).toEqual([]); + } + daemon.finishTransfer(); + await vi.advanceTimersByTimeAsync(30_000); + expect(client.getConnectionState()).toEqual({ status: "connected" }); + expect(daemon.closesFromClient()).toEqual([]); +}); + +test("an obsolete heartbeat cannot close a replacement connection", async () => { useHeartbeatClock(); const first = new FakeDaemon(); const second = new FakeDaemon(); @@ -2735,6 +2875,18 @@ test("sends only one ping while a slow pong is still outstanding", async () => { expect(session.pingTimestamps()).toEqual(["10s"]); }); +test("a heartbeat sharing a latency measurement still saves the RTT", async () => { + useHeartbeatClock(); + const session = new DaemonClientSession(); + session.daemonAnswersPingsAfter("2s"); + await session.connect(); + await session.advance(9_000); + const measurement = session.measureLatency({ timeoutMs: 5_000 }); + await session.advance(2_000); + await expect(measurement).resolves.toBe(2000); + expect(session.lastLivenessRttMs()).toBe(2000); +}); + test("reports the round-trip time of the last successful heartbeat", async () => { useHeartbeatClock(); const session = new DaemonClientSession(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 0c890f538f..79ff7f9f44 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1308,6 +1308,7 @@ interface PingProbe { reject: (error: Error) => void; timeoutHandle: ReturnType; startedAt: number; + receivedActivity: boolean; // Whether a timeout on this ping should be recorded as a liveness failure. Only the // heartbeat sets this; a latency measurement never drives teardown, even when a // heartbeat tick shares (dedupes onto) an in-flight measurement ping. @@ -1451,7 +1452,6 @@ export class DaemonClient { private runtimeMetricsInterval: ReturnType | null = null; private runtimeMetrics: DaemonClientRuntimeMetrics | null = null; private pingProbe: PingProbe | null = null; - private connectionVerification: DaemonTransport | null = null; private livenessHeartbeatTimer: ReturnType | null = null; private lastLivenessRttMs: number | null = null; private consecutiveLivenessFailures = 0; @@ -1601,6 +1601,7 @@ export class DaemonClient { send: (frame) => transport.send(frame), onFailure: () => transport.close(4003, "Transport send failed"), deliver: (frame) => this.handleTransportMessage(frame), + onActivity: () => this.recordTransportActivity(), }); this.lastServerInfoMessage = null; this.authFailureReasonValue = null; @@ -1797,7 +1798,7 @@ export class DaemonClient { this.shouldReconnect = true; } if (this.connectionState.status === "connected") { - if (options?.verify) this.verifyConnection(); + if (options?.verify) this.tickLivenessHeartbeat(); return; } if (this.connectionState.status === "connecting") return; @@ -1810,26 +1811,9 @@ export class DaemonClient { ); } - private verifyConnection(): void { - const transport = this.transport; - if (!transport || this.connectionVerification === transport) return; - this.connectionVerification = transport; - // A session probe has its own deadline, independent of a heartbeat that the OS - // may have suspended. A successful response also proves the session can serve RPCs. - void this.ping({ timeoutMs: 3_000 }) - .catch((error: unknown) => { - if (this.transport !== transport || this.connectionState.status !== "connected") return; - this.disposeTransport(1001, "Connection verification failed"); - this.scheduleReconnect({ - reason: error instanceof Error ? error.message : String(error), - event: "CONNECTION_VERIFICATION_FAILED", - reasonCode: "liveness_timeout", - }); - this.ensureConnected(); - }) - .finally(() => { - if (this.connectionVerification === transport) this.connectionVerification = null; - }); + private recordTransportActivity(): void { + this.consecutiveLivenessFailures = 0; + if (this.pingProbe) this.pingProbe.receivedActivity = true; } requireConnectedHosts(): void { @@ -2561,9 +2545,18 @@ export class DaemonClient { measureLatency(params?: { timeoutMs?: number }): Promise { const timeoutMs = Math.max(1, params?.timeoutMs ?? DEFAULT_LIVENESS_TIMEOUT_MS); - return this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: false }).catch((error) => { - throw toTimeoutError(error, "Latency measurement", timeoutMs); + let deadline: ReturnType; + const timeout = new Promise((_resolve, reject) => { + deadline = setTimeout(() => reject(new PingTimeoutError(timeoutMs)), timeoutMs); }); + return Promise.race([ + this.sendPingAwaitRtt({ timeoutMs, drivesLivenessFailure: false }), + timeout, + ]) + .catch((error) => { + throw toTimeoutError(error, "Latency measurement", timeoutMs); + }) + .finally(() => clearTimeout(deadline)); } private async livenessPing(params?: { timeoutMs?: number }): Promise { @@ -2599,21 +2592,23 @@ export class DaemonClient { resolveProbe = resolve; rejectProbe = reject; }); + const expire = () => { + if (this.pingProbe !== probe) return; + this.pingProbe = null; + const error = new PingTimeoutError(timeoutMs); + probe.reject(error); + // Keep the deadline fixed so subsequent pings renew the daemon socket lease + // during a long download. Progress proves life even when its pong is queued. + if (probe.drivesLivenessFailure && !probe.receivedActivity) { + this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs)); + } + }; const probe: PingProbe = { promise, resolve: (value) => resolveProbe?.(value), reject: (error) => rejectProbe?.(error), - timeoutHandle: setTimeout(() => { - if (this.pingProbe !== probe) { - return; - } - this.pingProbe = null; - const error = new PingTimeoutError(timeoutMs); - probe.reject(error); - if (probe.drivesLivenessFailure) { - this.recordLivenessFailure(toTimeoutError(error, "Liveness check", timeoutMs)); - } - }, timeoutMs), + timeoutHandle: setTimeout(expire, timeoutMs), + receivedActivity: false, startedAt, drivesLivenessFailure: params.drivesLivenessFailure, }; @@ -2654,14 +2649,21 @@ export class DaemonClient { } this.livenessHeartbeatTimer = setTimeout(() => { this.livenessHeartbeatTimer = null; - this.livenessPing({ timeoutMs: LIVENESS_HEARTBEAT_TIMEOUT_MS }) - .catch(() => {}) - .finally(() => { - this.scheduleNextLivenessHeartbeat(); - }); + this.tickLivenessHeartbeat(); }, LIVENESS_HEARTBEAT_INTERVAL_MS); } + private tickLivenessHeartbeat(): void { + this.stopLivenessHeartbeat(); + const transport = this.transport; + const tick = this.livenessPing({ timeoutMs: LIVENESS_HEARTBEAT_TIMEOUT_MS }); + void tick + .catch(() => {}) + .finally(() => { + if (this.transport === transport) this.scheduleNextLivenessHeartbeat(); + }); + } + // ============================================================================ // Agent RPCs (requestId-correlated) // ============================================================================ @@ -3662,6 +3664,22 @@ export class DaemonClient { return { pinnedAt: payload.pinnedAt }; } + async setWorkspaceBaseBranch(options: { + workspaceId: string; + baseRef: string; + requestId?: string; + }): Promise<{ baseRef: string | null }> { + const { requestId, workspaceId, baseRef } = options; + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.base_branch.set.response">({ + requestId, + message: { type: "workspace.base_branch.set.request", workspaceId, baseRef }, + }); + if (!payload.accepted) + throw new Error(payload.error ?? "Changing the workspace base branch failed"); + return { baseRef: payload.baseRef }; + } + async inspectWorkspaceRecovery( workspaceId: string, requestId?: string, @@ -5260,7 +5278,7 @@ export class DaemonClient { } async getBranchSuggestions( - options: { cwd: string; query?: string; limit?: number }, + options: { cwd: string; query?: string; limit?: number; refMode?: "exact" }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ @@ -5270,6 +5288,7 @@ export class DaemonClient { cwd: options.cwd, query: options.query, limit: options.limit, + refMode: options.refMode, }, responseType: "branch_suggestions_response", }); diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index dbebaa25c5..4bf0d1f8b1 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -607,6 +607,29 @@ test("workspace handles keep identity and refresh snapshots through existing dri }), ); await expect(titlePromise).resolves.toEqual({ title: "SDK review" }); + const basePromise = workspace.setBaseBranch({ + baseRef: "refs/heads/release", + requestId: "workspace-base-request", + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "workspace.base_branch.set.request", + workspaceId: "workspace_sdk", + baseRef: "refs/heads/release", + requestId: "workspace-base-request", + }); + ws.message( + sessionMessage({ + type: "workspace.base_branch.set.response", + payload: { + requestId: "workspace-base-request", + workspaceId: "workspace_sdk", + accepted: true, + baseRef: "refs/heads/release", + error: null, + }, + }), + ); + await expect(basePromise).resolves.toEqual({ baseRef: "refs/heads/release" }); unsubscribe(); ws.message( diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index e22e1678d8..c03ed65a46 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -195,6 +195,10 @@ export interface PaseoWorkspaceHandle { setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; /** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */ setMetadata(patch: Record): Promise; + setBaseBranch(options: { + baseRef: string; + requestId?: string; + }): Promise<{ baseRef: string | null }>; archive(requestId?: string): Promise; /** * Subscribes to already-emitted daemon workspace_update events for this id. @@ -923,6 +927,8 @@ function createWorkspaceHandleFactory( const metadata = await daemonClient.setWorkspaceMetadata(id, patch); if (current) current = { ...current, metadata }; }, + setBaseBranch: (options) => + daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }), archive: async (requestId) => { const result = await daemonClient.archiveWorkspace(id, requestId); if (current) { diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 9c275d6cc9..f06860d862 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -1056,6 +1056,10 @@ async function runRegression({ await page.screenshot({ path: path.join(artifactDir, "local-page-screenshot-selector.png") }); await originalDeck.getByRole("button", { name: "Cancel element selector" }).click(); + // This journey selects tabs from both pane rails. Keep both columns above the compact-header + // breakpoint; narrow panes deliberately replace their rail with a tab dropdown. + await resizeDesktopWindow(inspectorPort, { width: 1920, height: 1080 }); + await page.waitForFunction(() => innerWidth >= 1920, null, { timeout: timeoutMs }); await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click(); await page.getByTestId("sidebar-search").click(); await page.getByTestId("command-center-input").fill("Split pane right"); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0d92757b2b..7e3b01dfaa 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/desktop", - "version": "0.15.0", + "version": "0.15.2", "private": true, "description": "Paseo desktop app (Electron wrapper)", "homepage": "https://paseo.sh", diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json index cf8fdbdce6..b9c1190375 100644 --- a/packages/expo-two-way-audio/package.json +++ b/packages/expo-two-way-audio/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/expo-two-way-audio", - "version": "0.15.0", + "version": "0.15.2", "description": "Native module for two way audio streaming", "keywords": [ "ExpoTwoWayAudio", diff --git a/packages/highlight/package.json b/packages/highlight/package.json index 9356707c85..85d9c90471 100644 --- a/packages/highlight/package.json +++ b/packages/highlight/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/highlight", - "version": "0.15.0", + "version": "0.15.2", "license": "Apache-2.0", "files": [ "dist", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7c355d2062..ef537c35e3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/plugin", - "version": "0.15.0", + "version": "0.15.2", "description": "Paseo plugin SDK package", "license": "Apache-2.0", "files": [ @@ -78,8 +78,8 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { - "@getpaseo/client": "0.15.0", - "@getpaseo/protocol": "0.15.0", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", "@types/node": "^20.9.0", "@types/react": "~19.2.0", "react": "19.2.3", @@ -89,8 +89,8 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@getpaseo/client": "0.15.0", - "@getpaseo/protocol": "0.15.0", + "@getpaseo/client": "0.15.2", + "@getpaseo/protocol": "0.15.2", "react": ">=19.1.0 <20", "react-native": ">=0.81.5", "zod": "^4.4.3" diff --git a/packages/protocol/package.json b/packages/protocol/package.json index 975ad5833d..4667c9abe1 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/protocol", - "version": "0.15.0", + "version": "0.15.2", "description": "Paseo shared protocol schemas and wire types", "license": "Apache-2.0", "files": [ diff --git a/packages/protocol/src/agent-state-bucket.test.ts b/packages/protocol/src/agent-state-bucket.test.ts index 3c5d281b39..89d1ef93ab 100644 --- a/packages/protocol/src/agent-state-bucket.test.ts +++ b/packages/protocol/src/agent-state-bucket.test.ts @@ -39,6 +39,28 @@ describe("deriveAgentStateBucket", () => { ).toBe("failed"); }); + it("treats an errored agent whose error was read as done", () => { + expect( + deriveAgentStateBucket({ + status: "error", + pendingPermissionCount: 0, + requiresAttention: false, + attentionReason: null, + }), + ).toBe("done"); + }); + + it("treats an errored agent with unread error attention as failed", () => { + expect( + deriveAgentStateBucket({ + status: "error", + pendingPermissionCount: 0, + requiresAttention: true, + attentionReason: "error", + }), + ).toBe("failed"); + }); + it("treats unread finished agents as attention", () => { expect( deriveAgentStateBucket({ @@ -83,10 +105,21 @@ describe("getAgentStatusPriority", () => { it("prioritizes pending permissions before errors and running agents", () => { const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 }); expect(permission).toBeLessThan( - getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }), + getAgentStatusPriority({ + status: "error", + pendingPermissionCount: 0, + requiresAttention: true, + attentionReason: "error", + }), ); expect(permission).toBeLessThan( getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }), ); }); + + it("ranks an errored agent with a read error alongside completed agents", () => { + expect(getAgentStatusPriority({ status: "error", requiresAttention: false })).toBe( + getAgentStatusPriority({ status: "idle" }), + ); + }); }); diff --git a/packages/protocol/src/agent-state-bucket.ts b/packages/protocol/src/agent-state-bucket.ts index 2973152f1d..746d70a4e2 100644 --- a/packages/protocol/src/agent-state-bucket.ts +++ b/packages/protocol/src/agent-state-bucket.ts @@ -19,11 +19,17 @@ const WORKSPACE_STATE_BUCKET_PRIORITY = { done: 4, } as const satisfies Record; +// An error is a notification like a finish: once the user has seen it, the agent stops showing as +// failed even though its lifecycle stays "error" until the next turn starts. +function hasUnreadError(input: AgentStateBucketInput): boolean { + return input.requiresAttention === true && input.attentionReason === "error"; +} + export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket { if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") { return "needs_input"; } - if (input.status === "error" || input.attentionReason === "error") { + if (hasUnreadError(input)) { return "failed"; } if (input.status === "running") { @@ -43,7 +49,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number { if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") { return 0; } - if (input.status === "error" || input.attentionReason === "error") { + if (hasUnreadError(input)) { return 1; } if (input.status === "running") { diff --git a/packages/protocol/src/chunked-transport.ts b/packages/protocol/src/chunked-transport.ts index a32d68424f..8faa22cd96 100644 --- a/packages/protocol/src/chunked-transport.ts +++ b/packages/protocol/src/chunked-transport.ts @@ -25,6 +25,7 @@ export class ChunkedTransport { supportsDrain?: boolean; send: (frame: Frame, waitForDrain: boolean) => void | Promise; deliver: (frame: Frame) => void; + onActivity?: () => void; onFailure?: (error: unknown) => void; }, ) { @@ -134,6 +135,7 @@ export class ChunkedTransport { { cause: error }, ); } + this.io.onActivity?.(); if (message !== undefined) this.io.deliver(message); } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index befdd4f17b..53b2694ad2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1098,6 +1098,13 @@ export const WorkspacePinSetRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceBaseBranchSetRequestSchema = z.object({ + type: z.literal("workspace.base_branch.set.request"), + workspaceId: z.string(), + baseRef: z.string(), + requestId: z.string(), +}); + export const WorkspaceLabelColorSchema = z.enum(WORKSPACE_LABEL_COLORS); export const WorkspaceLabelDefinitionSchema = z.object({ name: z.string(), @@ -2281,6 +2288,17 @@ export const WorkspacePinSetResponseSchema = z.object({ payload: WorkspacePinSetResponsePayloadSchema, }); +export const WorkspaceBaseBranchSetResponseSchema = z.object({ + type: z.literal("workspace.base_branch.set.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + baseRef: z.string().nullable(), + error: z.string().nullable(), + }), +}); + export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("recoverable"), @@ -2598,6 +2616,7 @@ export const BranchSuggestionsRequestSchema = z.object({ cwd: z.string(), query: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), + refMode: z.literal("exact").optional(), requestId: z.string(), }); @@ -3424,6 +3443,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentMetadataUpdateRequestSchema, WorkspaceMetadataUpdateRequestSchema, WorkspacePinSetRequestSchema, + WorkspaceBaseBranchSetRequestSchema, WorkspaceLabelListRequestSchema, WorkspaceLabelAssignmentSetRequestSchema, WorkspaceLabelUpdateRequestSchema, @@ -3809,6 +3829,8 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), + // COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14. + workspaceBaseBranch: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. workspaceSetupRun: z.boolean().optional(), // COMPAT(workspaceTerminals): added in v0.8.0, remove gate after 2027-09-05. @@ -4266,6 +4288,8 @@ export const WorkspaceGitHubRuntimePayloadSchema = z export const WorkspaceDescriptorPayloadSchema = z .object({ + // Informational creation ancestry; independent of placement and lifecycle. + parentWorkspaceId: z.string().optional(), id: z.string(), projectId: z.string(), projectDisplayName: z.string(), @@ -5505,6 +5529,7 @@ const CheckoutStatusCommonSchema = z.object({ cwd: z.string(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), + baseRefExact: z.string().nullable().optional(), // The full ref currentBranch tracks, as git resolves `@{upstream}`: // "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a // "refs/heads/..." ref for a branch tracking a local branch. Null when there is no @@ -7318,6 +7343,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentMetadataUpdateResponseSchema, WorkspaceMetadataUpdateResponseSchema, WorkspacePinSetResponseSchema, + WorkspaceBaseBranchSetResponseSchema, WorkspaceRecoveryInspectResponseSchema, WorkspaceRecoveryRestoreResponseSchema, WaitForFinishResponseMessageSchema, @@ -7690,6 +7716,7 @@ export type AgentMetadataUpdateRequest = z.infer; export type AgentMetadataUpdateResponse = z.infer; export type WorkspaceMetadataUpdateResponse = z.infer; +export type WorkspaceBaseBranchSetRequest = z.infer; export type WorkspaceRecoveryInspectRequest = z.infer; export type WorkspaceRecoveryRestoreRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index abc89b06aa..a15c1ae1be 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -12,8 +12,65 @@ import { WorkspaceSetupSnapshotSchema, WorkspaceSetupProgressMessageSchema, AgentTimelineEntryPayloadSchema, + CheckoutStatusResponseSchema, + BranchSuggestionsRequestSchema, + WorkspaceBaseBranchSetRequestSchema, + WorkspaceBaseBranchSetResponseSchema, } from "./messages.js"; +test("base selection RPCs are correlated while older checkout and branch messages remain valid", () => { + const payload = { + cwd: "/repo", + requestId: "status", + error: null, + isGit: true, + isPaseoOwnedWorktree: false, + repoRoot: "/repo", + currentBranch: "feature", + isDirty: false, + baseRef: "release", + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + }; + expect( + CheckoutStatusResponseSchema.safeParse({ type: "checkout_status_response", payload }).success, + ).toBe(true); + expect( + CheckoutStatusResponseSchema.parse({ + type: "checkout_status_response", + payload: { ...payload, baseRefExact: "refs/remotes/upstream/release" }, + }).payload.baseRefExact, + ).toBe("refs/remotes/upstream/release"); + expect( + BranchSuggestionsRequestSchema.safeParse({ + type: "branch_suggestions_request", + cwd: "/repo", + requestId: "branches", + }).success, + ).toBe(true); + const request = { + type: "workspace.base_branch.set.request", + workspaceId: "workspace", + baseRef: "refs/heads/release", + requestId: "base", + }; + expect(WorkspaceBaseBranchSetRequestSchema.parse(request)).toEqual(request); + const response = { + type: "workspace.base_branch.set.response", + payload: { + workspaceId: "workspace", + baseRef: "refs/heads/release", + requestId: "base", + accepted: true, + error: null, + }, + }; + expect(WorkspaceBaseBranchSetResponseSchema.parse(response)).toEqual(response); +}); + test("terminal listings accept older rows and retain new per-terminal directories", () => { const response = { type: "list_terminals_response", diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 3cb9b0d5bd..8a5bd13224 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -634,6 +634,26 @@ describe("workspace message schemas", () => { ]); }); + test("preserves informational workspace parentage in directory updates", () => { + const workspace = { + id: "child", + parentWorkspaceId: "parent", + projectId: "project", + projectDisplayName: "repo", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "child", + status: "done", + activityAt: null, + }; + expect(WorkspaceDescriptorPayloadSchema.parse(workspace).parentWorkspaceId).toBe("parent"); + expect( + WorkspaceDescriptorPayloadSchema.parse({ ...workspace, parentWorkspaceId: undefined }) + .parentWorkspaceId, + ).toBeUndefined(); + }); + test("parses legacy workspace descriptors without workspaceDirectory", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "workspace_update", diff --git a/packages/relay/package.json b/packages/relay/package.json index 52d892733f..6f17d20847 100644 --- a/packages/relay/package.json +++ b/packages/relay/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/relay", - "version": "0.15.0", + "version": "0.15.2", "description": "Paseo relay for bridging daemon and client connections", "license": "Apache-2.0", "files": [ diff --git a/packages/server/package.json b/packages/server/package.json index bba90e58cd..471e558b77 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/server", - "version": "0.15.0", + "version": "0.15.2", "description": "Paseo backend server", "license": "Apache-2.0", "files": [ @@ -108,11 +108,11 @@ "@agentclientprotocol/sdk": "^0.17.1", "@anthropic-ai/claude-agent-sdk": "0.3.246", "@anthropic-ai/sdk": "^0.104.2", - "@getpaseo/client": "0.15.0", - "@getpaseo/highlight": "0.15.0", - "@getpaseo/plugin": "0.15.0", - "@getpaseo/protocol": "0.15.0", - "@getpaseo/relay": "0.15.0", + "@getpaseo/client": "0.15.2", + "@getpaseo/highlight": "0.15.2", + "@getpaseo/plugin": "0.15.2", + "@getpaseo/protocol": "0.15.2", + "@getpaseo/relay": "0.15.2", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/sdk": "1.14.46", diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 4e9ce49528..6a1d85bcf0 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -454,6 +454,34 @@ second line'`, ).toThrow("Fork from a later completed response"); }); + it("forks from a checkpoint when a plugin card was updated after it", () => { + const card = (seq: number, turns: number) => + row(seq, { + type: "plugin", + id: "memory-receipt", + pluginId: "memorise", + kind: "memory-receipt", + version: 1, + data: { turns }, + }); + const result = buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "First question", messageId: "user-1" }), + card(2, 1), + row(3, { type: "assistant_message", text: "First answer.", messageId: "assistant-1" }), + row(4, { type: "user_message", text: "Second question", messageId: "user-2" }), + row(5, { type: "assistant_message", text: "Second answer.", messageId: "assistant-2" }), + card(6, 2), + ], + }); + + expect(result.attachment.text).toContain("[User] First question"); + expect(result.attachment.text).toContain("[Assistant] First answer."); + expect(result.attachment.text).not.toContain("Second question"); + expect(result.attachment.text).not.toContain("memory-receipt"); + }); + it("selects a synthetic assistant error by its timeline cursor", () => { const result = buildAgentForkContextAttachment({ cursorBoundary: { diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 2a7cbff5e6..a0c537254a 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -13,6 +13,12 @@ const MAX_TOOL_SUMMARY_CHARS = 200; const MAX_FORK_CONTEXT_CHARS = 100_000; const MAX_FORK_METADATA_CHARS = 1_000; const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n"; +// A fork copies only these kinds, so only they can make a checkpoint stale. +const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [ + "user_message", + "assistant_message", + "tool_call", +]; interface ActivityCuratorOptions { maxItems?: number; @@ -269,7 +275,14 @@ function selectForkContextRows(input: { ); } const boundarySeq = projectedRows[boundaryIndex].seqEnd; - if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) { + if ( + projectedRows.some( + (row) => + FORK_CONTEXT_KINDS.includes(row.item.type) && + row.seqStart <= boundarySeq && + row.seqEnd > boundarySeq, + ) + ) { throw new Error( "This checkpoint changed after it was created. Fork from a later completed response instead.", ); @@ -349,7 +362,7 @@ export function buildAgentForkContextAttachment(input: { const entries = curateProjectedActivityEntries(selected.items, { maxItems: 0, labelAssistantMessages: true, - includeKinds: ["user_message", "assistant_message", "tool_call"], + includeKinds: FORK_CONTEXT_KINDS, includeExternalToolInput: false, }); const metadata = { agentTitle: input.agentTitle, cwd: input.cwd }; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 8247605e91..ab778fd80c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2818,6 +2818,53 @@ test("closing during reload leaves the replacement closed", async () => { } }); +test("every failed reload raises error attention, replacing unread finished attention", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-error-attention-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const attentionReasons: Array<"finished" | "error" | "permission"> = []; + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + rescueTimeouts: { reloadSessionCloseMs: 10 }, + onAgentAttention: ({ reason }) => attentionReasons.push(reason), + }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.markAgentUnread(created.id); + expect(manager.getAgent(created.id)?.attention).toMatchObject({ + attentionReason: "finished", + }); + + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing"); + + expect(manager.getAgent(created.id)?.lifecycle).toBe("error"); + expect(manager.getAgent(created.id)?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + expect(attentionReasons).toEqual(["error"]); + + await manager.clearAgentAttention(created.id); + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing"); + expect(manager.getAgent(created.id)?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + expect(attentionReasons).toEqual(["error", "error"]); + + client.finishClosing(); + await manager.closeAgent(created.id); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("retrying a timed-out reload waits for the original close to finish", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-late-close-")); const storage = new AgentStorage(join(workdir, "agents"), logger); @@ -5934,6 +5981,52 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async ( expect(last.lifecycle).toBe("closed"); }); +test("markAgentUnread restores error attention on a read errored agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-error-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class ErrorClient extends TestAgentClient { + readonly session = new TestAgentSession({ provider: "codex", cwd: workdir }); + + override async createSession(): Promise { + return this.session; + } + } + const client = new ErrorClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + onTestFinished(async () => { + await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const running = waitForAgentLifecycle(manager, agent.id, "running"); + client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" }); + await running; + const failed = waitForAgentLifecycle(manager, agent.id, "error"); + client.session.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "failed-turn", + error: "Selected model is at capacity.", + }); + await failed; + await manager.clearAgentAttention(agent.id); + + await manager.markAgentUnread(agent.id); + + expect(manager.getAgent(agent.id)?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + expect(await storage.get(agent.id)).toMatchObject({ + lastStatus: "error", + requiresAttention: true, + attentionReason: "error", + }); +}); + test("markAgentUnread dispatches stored attention to every subscriber", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-")); const storage = new AgentStorage(join(workdir, "agents"), logger); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index afa6d51848..b4347446ed 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -420,6 +420,13 @@ function raisesAttention(agent: Pick): return !agent.internal && !agent.background; } +/** The attention a read agent gets back when marked unread; null while it is still working. */ +function unreadAttentionReason(status: AgentLifecycleStatus): "finished" | "error" | null { + if (status === "idle" || status === "closed") return "finished"; + if (status === "error") return "error"; + return null; +} + function resolveInitialAttention(input: AttentionState | undefined): AttentionState { if (input == null || !input.requiresAttention) { return { requiresAttention: false }; @@ -1821,6 +1828,7 @@ export class AgentManager { } else if (this.agents.get(agentId) === existing) { existing.lifecycle = "error"; existing.lastError = error instanceof Error ? error.message : String(error); + this.raiseErrorAttention(existing); this.emitState(existing); } throw error; @@ -2591,16 +2599,16 @@ export class AgentManager { async markAgentUnread(agentId: string): Promise { const liveAgent = this.agents.get(agentId); if (liveAgent) { - const isFinished = liveAgent.lifecycle === "idle"; + const attentionReason = unreadAttentionReason(liveAgent.lifecycle); const hasPendingPermissions = liveAgent.pendingPermissions.size > 0; const canMarkUnread = - isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions; + attentionReason && !liveAgent.attention.requiresAttention && !hasPendingPermissions; if (!canMarkUnread) { - throw new Error(`Agent is no longer finished and read: ${agentId}`); + throw new Error(`Agent is no longer settled and read: ${agentId}`); } liveAgent.attention = { requiresAttention: true, - attentionReason: "finished", + attentionReason, attentionTimestamp: new Date(), }; await this.persistSnapshot(liveAgent); @@ -2610,18 +2618,18 @@ export class AgentManager { const registry = this.requireRegistry(); const record = await registry.get(agentId); - const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed"; + const attentionReason = record?.lastStatus ? unreadAttentionReason(record.lastStatus) : null; const canMarkUnread = record && !record.internal && !record.archivedAt && !record.requiresAttention; - if (!canMarkUnread || !hasFinishedStatus) { - throw new Error(`Agent is no longer finished and read: ${agentId}`); + if (!canMarkUnread || !attentionReason) { + throw new Error(`Agent is no longer settled and read: ${agentId}`); } const updatedAt = this.nextStoredUpdatedAt(record); const nextRecord: StoredAgentRecord = { ...record, updatedAt, requiresAttention: true, - attentionReason: "finished", + attentionReason, attentionTimestamp: updatedAt, }; await registry.upsert(nextRecord); @@ -5497,12 +5505,15 @@ export class AgentManager { return; } - // Skip if already requires attention + if (previousStatus !== "error" && currentStatus === "error") { + this.raiseErrorAttention(agent); + return; + } + if (agent.attention.requiresAttention) { return; } - // Check if agent transitioned from running to idle (finished) if (previousStatus === "running" && currentStatus === "idle") { agent.attention = { requiresAttention: true, @@ -5510,19 +5521,25 @@ export class AgentManager { attentionTimestamp: new Date(), }; this.broadcastAgentAttention(agent, "finished"); - return; } + } - // Check if agent entered error state - if (previousStatus !== "error" && currentStatus === "error") { - agent.attention = { - requiresAttention: true, - attentionReason: "error", - attentionTimestamp: new Date(), - }; - this.broadcastAgentAttention(agent, "error"); + // The failed state is only shown while error attention is unread, so every new failure raises + // it, including one that keeps the lifecycle at "error". An error outranks an unread finish, + // which would otherwise make the failure look green. + private raiseErrorAttention(agent: ManagedAgent): void { + if (!raisesAttention(agent)) { return; } + if (agent.attention.requiresAttention && agent.attention.attentionReason !== "finished") { + return; + } + agent.attention = { + requiresAttention: true, + attentionReason: "error", + attentionTimestamp: new Date(), + }; + this.broadcastAgentAttention(agent, "error"); } private enqueueBackgroundPersist(agent: ManagedAgent): void { diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 4b81efb607..f7d7d76d78 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -1171,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => { .callTool({ name: "archive_workspace", args: { workspaceId } }) .catch(() => undefined); } - }); + }, 30_000); test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => { const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", { @@ -1197,12 +1197,43 @@ describe("Suite E: Workspace Tools", () => { }); ownedWorkspaces.push(str(inherited.workspaceId)); expect(inherited.background).toBe(true); + expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId); const visible = await callToolStructured(scoped, "create_workspace", { isolation: "local", background: false, }); ownedWorkspaces.push(str(visible.workspaceId)); expect(visible.background).toBe(false); + expect(visible.parentWorkspaceId).toBe(parentWorkspaceId); + const worktree = await callToolStructured(scoped, "create_workspace", { + isolation: "worktree", + branchName: "parentage-child", + baseBranch: "main", + background: false, + }); + ownedWorkspaces.push(str(worktree.workspaceId)); + expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` }); + try { + await client.connect(); + const created = await client.createWorkspace({ + source: { kind: "directory", path: worktreeRepoCwd }, + callerAgentId: str(parent.agentId), + background: false, + }); + const workspaceId = str(created.workspace?.id); + ownedWorkspaces.push(workspaceId); + expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId); + const listed = await client.fetchWorkspaces(); + expect(listed.entries).toContainEqual( + expect.objectContaining({ + id: workspaceId, + parentWorkspaceId: parentWorkspaceId, + }), + ); + } finally { + await client.close(); + } } finally { await scoped?.close(); for (const workspaceId of ownedWorkspaces.toReversed()) @@ -1210,7 +1241,7 @@ describe("Suite E: Workspace Tools", () => { .callTool({ name: "archive_workspace", args: { workspaceId } }) .catch(() => undefined); } - }); + }, 30_000); test("a background agent's children and workspaces are background unless it overrides", async () => { const visibleAgentId = await createTopLevelAgent(); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 53c2e90d06..1107ad5e9d 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -189,6 +189,7 @@ interface ProviderSummary { } const WorkspaceAutomationSummarySchema = z.object({ + parentWorkspaceId: z.string().optional(), workspaceId: z.string(), projectId: z.string(), cwd: z.string(), @@ -207,6 +208,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { kind: workspace.kind, title: workspace.title, background: workspace.background, + parentWorkspaceId: workspace.parentWorkspaceId, }; } diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index addea53645..64ab7556e4 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -219,6 +219,7 @@ const INBOUND_PERMISSION = { "agent.metadata.update.request": "workspace.write", "workspace.metadata.update.request": "workspace.manage", "workspace.pin.set.request": "workspace.manage", + "workspace.base_branch.set.request": "workspace.write", "workspace.recovery.inspect.request": ["workspace.read", "hub.execute"], "workspace.recovery.restore.request": ["workspace.manage", "hub.execute"], "workspace.script.list.request": "workspace.read", @@ -464,6 +465,7 @@ const OUTBOUND_PERMISSION = { "agent.metadata.update.response": "workspace.write", "workspace.metadata.update.response": "workspace.manage", "workspace.pin.set.response": "workspace.manage", + "workspace.base_branch.set.response": "workspace.write", "workspace.recovery.inspect.response": ["workspace.read", "hub.execute"], "workspace.recovery.restore.response": ["workspace.manage", "hub.execute"], "workspace.script.list.response": "workspace.read", diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 917813d587..8293ce965f 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -195,6 +195,7 @@ export class CheckoutDiffManager { remoteUrl: snapshot.git.remoteUrl, isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, baseRef: snapshot.git.baseRef, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind, aheadOfOrigin: snapshot.git.aheadOfOrigin, behindOfOrigin: snapshot.git.behindOfOrigin, diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index 7f7365efca..90c900435c 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -61,6 +61,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ currentBranch: snapshot.git.currentBranch ?? null, isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, @@ -81,6 +82,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ currentBranch: snapshot.git.currentBranch ?? null, isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef ?? null, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, diff --git a/packages/server/src/server/git-metadata-event-rules.test.ts b/packages/server/src/server/git-metadata-event-rules.test.ts index 8c7e491970..4456573624 100644 --- a/packages/server/src/server/git-metadata-event-rules.test.ts +++ b/packages/server/src/server/git-metadata-event-rules.test.ts @@ -21,6 +21,7 @@ describe("Git metadata event rules", () => { ["worktree", "HEAD", { kind: "owner", refreshBase: true }], ["worktree", "config.worktree", { kind: "owner", refreshBase: true }], ["worktree", "paseo/worktree.json", { kind: "owner", refreshBase: true }], + ["common", "paseo/worktree.json", { kind: "owner", refreshBase: true }], ["worktree", "COMMIT_EDITMSG", { kind: "ignore" }], ["worktree", "ORIG_HEAD", { kind: "ignore" }], ["common", "index", { kind: "owner", refreshBase: false }], diff --git a/packages/server/src/server/git-metadata-event-rules.ts b/packages/server/src/server/git-metadata-event-rules.ts index dab6bc4887..ff82747ba0 100644 --- a/packages/server/src/server/git-metadata-event-rules.ts +++ b/packages/server/src/server/git-metadata-event-rules.ts @@ -44,7 +44,7 @@ export const GIT_METADATA_EVENT_RULES = [ }, { id: "paseo-worktree-metadata", - scope: "worktree", + scope: "both", match: "exact", path: "paseo/worktree.json", route: "owner", diff --git a/packages/server/src/server/plugins/managed-source.posix.test.ts b/packages/server/src/server/plugins/managed-source.posix.test.ts index 7b694f1ef3..440ef52074 100644 --- a/packages/server/src/server/plugins/managed-source.posix.test.ts +++ b/packages/server/src/server/plugins/managed-source.posix.test.ts @@ -575,3 +575,53 @@ it("installs and updates only the npm artifacts pinned by the plugin registry", else process.env.npm_config_userconfig = previous; } }, 30_000); + +it("installs a registry npm pin through the configured npm mirror", async () => { + const { startNpmRegistry, npmPluginPackages } = + await import("../../../../../scripts/test-support/npm-registry.mjs"); + const { readNpmArtifact, resolveNpm } = await import("./managed-source/npm.js"); + const mirror = await startNpmRegistry(npmPluginPackages()); + const previous = process.env.npm_config_userconfig; + process.env.npm_config_userconfig = mirror.userconfig; + // The public registry pins npmjs.org tarball URLs, which a mirror does not serve at the same path. + const pin = { + version: "1.0.0", + resolved: "https://registry.npmjs.org/paseo-fixture-plugin/-/paseo-fixture-plugin-1.0.0.tgz", + integrity: "", + }; + const server = createServer((_request, response) => { + response.end( + JSON.stringify({ + id: "acme/example", + artifact: { kind: "npm", package: "paseo-fixture-plugin", ...pin }, + }), + ); + }); + try { + const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-npm-mirror-")); + roots.push(home); + pin.integrity = (await resolveNpm("paseo-fixture-plugin", "1.0.0", home)).integrity; + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing registry address"); + const sources = new ManagedPluginSources(home, { + defaultUrl: `http://127.0.0.1:${address.port}`, + }); + const candidate = await sources.place( + "example", + await sources.prepareInstall({ source: "acme/example" }), + ); + await sources.verifyCandidate("example", candidate); + sources.commit("example", candidate.record); + expect((await sources.describe("example", candidate.directory)).currentRevision).toBe("1.0.0"); + expect( + await readNpmArtifact(path.resolve(candidate.directory, "../.."), "paseo-fixture-plugin"), + ).toEqual({ ...pin, resolved: `${mirror.url}/artifacts/1.tgz` }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await mirror.close(); + if (previous === undefined) delete process.env.npm_config_userconfig; + else process.env.npm_config_userconfig = previous; + } +}, 30_000); diff --git a/packages/server/src/server/plugins/managed-source.ts b/packages/server/src/server/plugins/managed-source.ts index 5373b7453a..a20be890b3 100644 --- a/packages/server/src/server/plugins/managed-source.ts +++ b/packages/server/src/server/plugins/managed-source.ts @@ -22,7 +22,13 @@ import { import { runGitCommand } from "../../utils/run-git-command.js"; import { ensurePrivateDirectory, writePrivateFileAtomicSync } from "../private-files.js"; import { readPluginManifest, type PluginManifest } from "./manifest.js"; -import { acquireNpm, isNpmSource, readNpmArtifact, resolveNpm } from "./managed-source/npm.js"; +import { + acquireNpm, + isNpmSource, + isSameNpmArtifact, + readNpmArtifact, + resolveNpm, +} from "./managed-source/npm.js"; const GIT_TIMEOUT_MS = 120_000; const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const; @@ -346,11 +352,7 @@ export class ManagedPluginSources { throw new Error("Preparation changed the reviewed Git commit"); if (target?.kind === "npm" && location.identity.kind === "npm") { const artifact = await readNpmArtifact(location.versionRoot, location.identity.packageName); - if ( - artifact.version !== target.version || - artifact.integrity !== target.integrity || - artifact.resolved !== target.resolved - ) + if (!isSameNpmArtifact({ installed: artifact, target })) throw new Error("Preparation changed the reviewed npm artifact"); } } diff --git a/packages/server/src/server/plugins/managed-source/npm.ts b/packages/server/src/server/plugins/managed-source/npm.ts index 315bbc981e..419a807201 100644 --- a/packages/server/src/server/plugins/managed-source/npm.ts +++ b/packages/server/src/server/plugins/managed-source/npm.ts @@ -48,7 +48,7 @@ export async function acquireNpm(source: string, installRoot: string, target?: N name: "paseo-plugin-installation", version: "1.0.0", private: true, - dependencies: { [packageName]: target?.resolved ?? requestedSpec }, + dependencies: { [packageName]: requestedSpec }, }), ); const npm = await requireNpm(); @@ -70,23 +70,28 @@ export async function acquireNpm(source: string, installRoot: string, target?: N { cwd: installRoot, timeout: 120_000, maxBuffer: 1024 * 1024 }, ); const installed = await readNpmArtifact(installRoot, packageName); - if ( - target && - (installed.version !== target.version || - installed.integrity !== target.integrity || - installed.resolved !== target.resolved) - ) { + if (target && !isSameNpmArtifact({ installed, target })) { throw new Error(`npm artifact changed since review for ${packageName}`); } return { packageName, ...installed }; } +// Mirrors serve the reviewed tarball from their own URL, so the content hash identifies it. +export function isSameNpmArtifact({ installed, target }: NpmArtifactComparison): boolean { + return installed.version === target.version && installed.integrity === target.integrity; +} + export interface NpmArtifact { version: string; resolved: string; integrity: string; } +export interface NpmArtifactComparison { + installed: NpmArtifact; + target: NpmArtifact; +} + export async function readNpmArtifact( installRoot: string, packageName: string, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 6a9b5447e4..e335350977 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3,6 +3,12 @@ import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; import type { ConnectedHosts } from "./connected-hosts/index.js"; +import { + createWorkspaceBaseBranchService, + type WorkspaceBaseBranchService, +} from "./session/workspace-base-branch/service.js"; +import type { WorkspaceBaseBranchSetRequest } from "@getpaseo/protocol/messages"; + import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; @@ -484,6 +490,7 @@ export interface SessionOptions { creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceBaseBranch?: WorkspaceBaseBranchService; directorySync?: DirectorySyncService; workspaceLabelService?: WorkspaceLabelService; filesystem?: SessionFileSystem; @@ -780,6 +787,7 @@ export class Session { private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly workspaceRecovery: WorkspaceRecoveryService; + private workspaceBaseBranch: WorkspaceBaseBranchService | undefined; private readonly daemonConfigStore: DaemonConfigStore; private readonly pushNotifications: PushNotifications; private readonly pluginRuntime: SessionOptions["pluginRuntime"]; @@ -963,12 +971,22 @@ export class Session { this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; this.workspaceRuntimeStore = workspaceRuntimeStore; + this.workspaceBaseBranch = + options.workspaceBaseBranch ?? + createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); + this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock, lifecycle: this.pluginRuntime, serverId, workspaceRegistry: this.workspaceRegistry, @@ -2528,10 +2546,22 @@ export class Session { this.dispatchWorkspaceStateMessage(msg) ?? this.dispatchWorkspaceLabelMessage(msg) ?? this.dispatchWorkspaceSetupMessage(msg) ?? + this.dispatchMetadataMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) ); } + private dispatchMetadataMessage(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "agent.metadata.update.request": + return this.handleAgentMetadataUpdateRequest(msg); + case "workspace.metadata.update.request": + return this.handleWorkspaceMetadataUpdateRequest(msg); + default: + return undefined; + } + } + private dispatchOrchestrationSkillsMessage( msg: SessionInboundMessage, ): Promise | undefined { @@ -3218,10 +3248,8 @@ export class Session { return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId); - case "agent.metadata.update.request": - return this.handleAgentMetadataUpdateRequest(msg); - case "workspace.metadata.update.request": - return this.handleWorkspaceMetadataUpdateRequest(msg); + case "workspace.base_branch.set.request": + return this.handleWorkspaceBaseBranchSetRequest(msg); default: return undefined; } @@ -4160,6 +4188,41 @@ export class Session { } } + private async handleWorkspaceBaseBranchSetRequest( + msg: WorkspaceBaseBranchSetRequest, + ): Promise { + try { + this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); + const baseRef = await this.workspaceBaseBranch.setBaseBranch(msg); + this.emit({ + type: "workspace.base_branch.set.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: true, + baseRef, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.base_branch.set.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: false, + baseRef: null, + error: getErrorMessage(error), + }, + }); + } + } + private async handleWorkspacePinSetRequest( workspaceId: string, pinned: boolean, @@ -5931,6 +5994,7 @@ export class Session { ...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}), metadata: workspace.metadata, background: workspace.background, + parentWorkspaceId: workspace.parentWorkspaceId, archivingAt: null, status: "done", statusEnteredAt: null, @@ -6019,6 +6083,7 @@ export class Session { projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, background: result.workspace.background, + parentWorkspaceId: result.workspace.parentWorkspaceId, name: resolveWorkspaceName({ title: result.workspace.title, derivedDisplayName: result.worktree.branchName || result.workspace.displayName, @@ -8044,7 +8109,10 @@ export class Session { .filter((agent) => !agent.archivedAt && !agent.background) .filter((agent) => agent.workspaceId === workspace.workspaceId) .filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id) - .filter((agent) => agent.status === "idle" || agent.status === "closed") + .filter( + (agent) => + agent.status === "idle" || agent.status === "closed" || agent.status === "error", + ) .filter((agent) => agent.requiresAttention !== true) .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) .sort( @@ -8053,7 +8121,7 @@ export class Session { ); const candidate = candidates[0]; if (!candidate) { - throw new Error(`Workspace has no finished agent to mark unread: ${workspaceId}`); + throw new Error(`Workspace has no read agent to mark unread: ${workspaceId}`); } await this.agentManager.markAgentUnread(candidate.id); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index caff1b1b3d..890d9b8025 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -2124,7 +2124,53 @@ test("workspace mark unread selects a finished cross-workspace child with scoped } }); -test("workspace mark unread rejects workspaces without a finished root agent", async () => { +test("workspace mark unread marks a read errored root agent", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const markedAgentIds: string[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentManager: { + markAgentUnread: async (agentId: string) => { + markedAgentIds.push(agentId); + }, + }, + }); + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "errored-agent", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + status: "error", + updatedAt: "2026-03-30T16:00:00.000Z", + requiresAttention: false, + }), + ]; + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(markedAgentIds).toEqual(["errored-agent"]); + expect(findByType(emitted, "workspace.mark_unread.response").payload).toMatchObject({ + markedAgentId: "errored-agent", + success: true, + }); +}); + +test("workspace mark unread rejects workspaces without a read root agent", async () => { const emitted: SessionOutboundMessage[] = []; const workspace = createPersistedWorkspaceRecord({ workspaceId: REPO_CWD, @@ -2151,7 +2197,7 @@ test("workspace mark unread rejects workspaces without a finished root agent", a workspaceId: workspace.workspaceId, markedAgentId: null, success: false, - error: `Workspace has no finished agent to mark unread: ${workspace.workspaceId}`, + error: `Workspace has no read agent to mark unread: ${workspace.workspaceId}`, }); }); diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index ceb69e9623..4deb28a56b 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -379,13 +379,14 @@ export class CheckoutSession { } async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise { - const { cwd, query, limit, requestId } = msg; + const { cwd, query, limit, refMode, requestId } = msg; try { const resolvedCwd = expandTilde(cwd); const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { query, limit, + refMode, }); this.host.emit({ type: "branch_suggestions_response", diff --git a/packages/server/src/server/session/workspace-base-branch/service.ts b/packages/server/src/server/session/workspace-base-branch/service.ts new file mode 100644 index 0000000000..ffc8feffb0 --- /dev/null +++ b/packages/server/src/server/session/workspace-base-branch/service.ts @@ -0,0 +1,104 @@ +import { resolve } from "node:path"; +import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js"; +import { + readPaseoWorktreeMetadata, + setPaseoWorktreeBaseRef, +} from "../../../utils/worktree-metadata.js"; +import type { WorkspaceRegistry } from "../../workspace-registry.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; + +interface Dependencies { + paseoHome: string; + worktreesRoot?: string; + workspaceRegistry: WorkspaceRegistry; + workspaceGitService: WorkspaceGitService; +} + +export function createWorkspaceBaseBranchService(deps: Dependencies) { + const queues = new Map>(); + + async function withCheckoutLock(root: string, operation: () => Promise): Promise { + root = resolve(root); + const previous = queues.get(root); + let release!: () => void; + const queued = new Promise((done) => { + release = done; + }); + queues.set(root, queued); + await previous; + + try { + return await operation(); + } finally { + release(); + if (queues.get(root) === queued) queues.delete(root); + } + } + + async function setBaseBranch(input: { workspaceId: string; baseRef: string }): Promise { + const workspace = await deps.workspaceRegistry.get(input.workspaceId); + if (!workspace || workspace.archivedAt) throw new Error("Workspace is unavailable"); + const facts = await getCheckoutSnapshotFacts(workspace.cwd, { + paseoHome: deps.paseoHome, + worktreesRoot: deps.worktreesRoot, + }); + if (!facts.isGit) throw new Error("Workspace is not a Git checkout"); + if (!facts.paseoWorktree.isPaseoOwnedWorktree) + throw new Error("Workspace is not a Paseo worktree"); + const root = resolve(facts.worktreeRoot); + return withCheckoutLock(root, async () => { + const current = await deps.workspaceRegistry.get(input.workspaceId); + if (!current || current.archivedAt) throw new Error("Workspace is unavailable"); + const records = await deps.workspaceRegistry.listActive(); + const matching = records.filter( + (record) => + record.workspaceId === input.workspaceId || + resolve(record.worktreeRoot ?? record.cwd) === root, + ); + await deps.workspaceGitService.getSnapshot(root); + const metadata = await readPaseoWorktreeMetadata(root); + const baseRef = await setCheckoutBaseBranch({ + cwd: root, + baseRef: input.baseRef, + context: { paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot }, + }); + const updatedAt = new Date().toISOString(); + try { + for (const record of matching) { + await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({ + ...latest, + baseBranch: baseRef, + updatedAt, + })); + } + } catch (error) { + if (metadata) + await setPaseoWorktreeBaseRef({ + worktreeRoot: root, + baseRef: metadata.baseRef ?? metadata.baseRefName, + }); + for (const record of matching) { + await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({ + ...latest, + baseBranch: record.baseBranch, + updatedAt: record.updatedAt, + })); + } + throw error; + } + for (const cwd of new Set(matching.map((record) => record.cwd))) { + await deps.workspaceGitService.getSnapshot(cwd, { + force: true, + afterInFlight: true, + includeForge: false, + reason: "base-branch-change", + }); + } + return baseRef; + }); + } + + return { setBaseBranch, withCheckoutLock }; +} + +export type WorkspaceBaseBranchService = ReturnType; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index d9f7ccea96..706f199114 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -5,6 +5,11 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getCheckoutStatus } from "../../../utils/checkout-git.js"; +import { + setPaseoWorktreeBaseRef, + writePaseoWorktreeMetadata, +} from "../../../utils/worktree-metadata.js"; +import { createWorkspaceBaseBranchService } from "../workspace-base-branch/service.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createNoGitWorkspaceRuntimeSnapshot, @@ -125,6 +130,62 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); +test("a workspace created during a base change records the new base", async () => { + const cwd = path.join(tmpDir, "worktree"); + mkdirSync(path.join(cwd, ".git"), { recursive: true }); + await writePaseoWorktreeMetadata(cwd, { baseRefName: "main", baseRef: "refs/heads/main" }); + const git = createNoopWorkspaceGitService({ + getCheckout: async () => ({ + cwd, + isGit: true, + currentBranch: "feature", + remoteUrl: null, + worktreeRoot: cwd, + isPaseoOwnedWorktree: true, + mainRepoRoot: cwd, + }), + }); + const bases = createWorkspaceBaseBranchService({ + paseoHome: tmpDir, + workspaceRegistry, + workspaceGitService: git, + }); + let release!: () => void; + let entered!: () => void; + let attempted!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const locked = new Promise((resolve) => { + entered = resolve; + }); + const waiting = new Promise((resolve) => { + attempted = resolve; + }); + const change = bases.withCheckoutLock(cwd, async () => { + entered(); + await gate; + await setPaseoWorktreeBaseRef({ worktreeRoot: cwd, baseRef: "refs/heads/release" }); + }); + await locked; + const creator = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: git, + isDirectory, + logger, + withCheckoutLock: (root, operation) => { + attempted(); + return bases.withCheckoutLock(root, operation); + }, + }); + const created = creator.createWorkspaceForDirectory(cwd); + await waiting; + release(); + await change; + expect((await created).baseBranch).toBe("refs/heads/release"); +}); + test("fresh git repo creates a workspace at the canonical worktree root", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); @@ -144,6 +205,82 @@ test("fresh non-git directory creates a directory workspace at the exact path", expect(workspace.cwd).toBe(dir); }); +test("workspace parentage survives reload and parent archive without changing placement", async () => { + const cwd = path.join(tmpDir, "plain"); + const parent = await provisioning.createWorkspaceForDirectory(cwd); + const child = await provisioning.createWorkspaceForDirectory(cwd, null, undefined, { + callerWorkspaceId: parent.workspaceId, + background: false, + }); + const grandchild = await provisioning.createWorkspaceForDirectory( + path.join(tmpDir, "other"), + null, + undefined, + { + callerWorkspaceId: child.workspaceId, + }, + ); + expect(parent.parentWorkspaceId).toBeUndefined(); + expect(child).toMatchObject({ parentWorkspaceId: parent.workspaceId, cwd }); + expect(grandchild).toMatchObject({ parentWorkspaceId: child.workspaceId }); + await workspaceRegistry.archive(parent.workspaceId, ARCHIVED_AT); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloaded.initialize(); + expect(await reloaded.get(child.workspaceId)).toMatchObject({ + parentWorkspaceId: parent.workspaceId, + archivedAt: null, + }); + expect(await reloaded.get(grandchild.workspaceId)).toMatchObject({ + parentWorkspaceId: child.workspaceId, + archivedAt: null, + }); +}); + +test("worktree workspace creation records its caller independently of its source project", async () => { + const sourceCwd = path.join(tmpDir, "repo"); + gitRoots.add(sourceCwd); + const parent = await provisioning.createWorkspaceForDirectory(path.join(tmpDir, "other")); + const child = await provisioning.createWorkspaceForWorktree({ + sourceCwd, + repoRoot: sourceCwd, + cwd: path.join(tmpDir, "worktree"), + worktreeRoot: path.join(tmpDir, "worktree"), + branch: "child", + baseBranch: "main", + title: null, + callerWorkspaceId: parent.workspaceId, + }); + expect(child.parentWorkspaceId).toBe(parent.workspaceId); + expect(child.projectId).not.toBe(parent.projectId); + expect(await workspaceRegistry.get(child.workspaceId)).toMatchObject({ + parentWorkspaceId: parent.workspaceId, + }); +}); + +test("workspace creation rejects an unknown caller even with explicit visibility", async () => { + await expect( + provisioning.createWorkspaceForDirectory(path.join(tmpDir, "plain"), null, undefined, { + callerWorkspaceId: "missing", + background: false, + }), + ).rejects.toThrow("Caller workspace missing not found"); +}); + +test("workspace creation cannot overwrite its caller with a self relationship", async () => { + const cwd = path.join(tmpDir, "plain"); + const parent = await provisioning.createWorkspaceForDirectory(cwd); + await expect( + provisioning.createWorkspaceForDirectory(cwd, null, undefined, { + callerWorkspaceId: parent.workspaceId, + workspaceId: parent.workspaceId, + }), + ).rejects.toThrow("A workspace cannot be its own parent"); + expect(await workspaceRegistry.get(parent.workspaceId)).toEqual(parent); +}); + test("background workspace creation preserves hooks, inheritance and durable visibility", async () => { const dir = path.join(tmpDir, "plain"); const events: string[] = []; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 9790770cf1..28a412e7e5 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -21,6 +21,7 @@ import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.j import { deriveProjectKey } from "../../project-key.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; import type { UntrustedWorkspaceSource } from "../../workspace-automation-gate.js"; +import { readPaseoWorktreeMetadata } from "../../../utils/worktree-metadata.js"; export interface ResolveOrCreateWorkspaceIdInput { createdWorktree: CreatePaseoWorktreeWorkflowResult | null; @@ -110,6 +111,7 @@ export function createWorkspaceProvisioningService(deps: { isDirectory: (path: string) => Promise; logger: Logger; lifecycle?: PluginLifecycle; + withCheckoutLock?: (root: string, operation: () => Promise) => Promise; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; @@ -228,15 +230,21 @@ export function createWorkspaceProvisioningService(deps: { return project; } - async function resolveBackground(input?: { + async function resolveCreationMetadata(input?: { + workspaceId?: string; background?: boolean; callerWorkspaceId?: string; - }): Promise { - if (input?.background !== undefined) return input.background; - if (!input?.callerWorkspaceId) return false; + }): Promise<{ background: boolean; parentWorkspaceId?: string }> { + if (!input?.callerWorkspaceId) return { background: input?.background ?? false }; + if (input.workspaceId === input.callerWorkspaceId) { + throw new Error("A workspace cannot be its own parent"); + } const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId); if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`); - return callerWorkspace.background; + return { + background: input.background ?? callerWorkspace.background, + parentWorkspaceId: callerWorkspace.workspaceId, + }; } async function createWorkspaceForDirectory( @@ -257,22 +265,32 @@ export function createWorkspaceProvisioningService(deps: { ? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout) : // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15. await findOrCreateProjectForDirectory(normalizedCwd); - const timestamp = new Date().toISOString(); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: context?.workspaceId ?? generateWorkspaceId(), - projectId: project.projectId, - ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), - title: title?.trim() || null, - metadata: context?.metadata, - createdAt: timestamp, - updatedAt: timestamp, - background: await resolveBackground(context), - }); - await workspaceRegistry.upsert(workspace, { - expectsInitialAgent: context?.expectsInitialAgent, - }); - emitWorkspaceCreated(workspace); - return workspace; + const create = async () => { + const baseMetadata = + checkout.isGit && checkout.isPaseoOwnedWorktree + ? await readPaseoWorktreeMetadata(checkout.worktreeRoot ?? normalizedCwd) + : null; + const timestamp = new Date().toISOString(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: context?.workspaceId ?? generateWorkspaceId(), + projectId: project.projectId, + ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), + baseBranch: baseMetadata?.baseRef ?? baseMetadata?.baseRefName ?? null, + title: title?.trim() || null, + metadata: context?.metadata, + createdAt: timestamp, + updatedAt: timestamp, + ...(await resolveCreationMetadata(context)), + }); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: context?.expectsInitialAgent, + }); + emitWorkspaceCreated(workspace); + return workspace; + }; + return deps.withCheckoutLock && checkout.isGit && checkout.isPaseoOwnedWorktree + ? deps.withCheckoutLock(checkout.worktreeRoot ?? normalizedCwd, create) + : create(); } async function createWorkspaceForWorktree( @@ -304,7 +322,7 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, ...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}), - background: await resolveBackground(input), + ...(await resolveCreationMetadata(input)), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 9f8cb411e0..649d21dfc3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -16,6 +16,10 @@ import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index"; import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js"; import { ConnectedHosts } from "./connected-hosts/index.js"; import { stat } from "node:fs/promises"; +import { + createWorkspaceBaseBranchService, + type WorkspaceBaseBranchService, +} from "./session/workspace-base-branch/service.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import { CreationService } from "./creation/index.js"; import { MessageReceipts } from "./message-receipts/index.js"; @@ -573,6 +577,7 @@ export class VoiceAssistantWebSocketServer { private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceBaseBranch: WorkspaceBaseBranchService; private readonly workspaceLabelService: WorkspaceLabelService | null; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; @@ -760,12 +765,18 @@ export class VoiceAssistantWebSocketServer { this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.github = github ?? createGitHubService(); this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); + this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; + this.workspaceBaseBranch = createWorkspaceBaseBranchService({ + paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() }); this.passwordHash = auth?.password; - this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; this.assignOptionalServices({ @@ -1665,6 +1676,7 @@ export class VoiceAssistantWebSocketServer { creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + workspaceBaseBranch: this.workspaceBaseBranch, workspaceLabelService: this.workspaceLabelService ?? undefined, directorySync: this.directorySync, scheduleService: this.scheduleService, @@ -2022,6 +2034,7 @@ export class VoiceAssistantWebSocketServer { backgroundWorkspaces: true, // COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03. backgroundAgents: true, + workspaceBaseBranch: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index df229f9221..d10a098d79 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; import { createWorktree } from "../utils/worktree.js"; +import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; import { createDaemonTestContext, DaemonClient, @@ -277,6 +278,118 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test("a local checkout rejects a base change without creating worktree metadata", async () => { + const repo = createGitRepo(); + const id = await createLocalWorkspace(repo, "Local"); + await expect( + ctx.client.setWorkspaceBaseBranch({ workspaceId: id, baseRef: "refs/heads/main" }), + ).rejects.toThrow("not a Paseo worktree"); + expect(await readPaseoWorktreeMetadata(repo)).toBeNull(); +}); + +test("changing a worktree base refreshes its diff and persists across restart", async () => { + const repo = createGitRepo(); + execFileSync("git", ["checkout", "-b", "release"], { cwd: repo }); + writeFileSync(path.join(repo, "release.txt"), "release\n"); + execFileSync("git", ["add", "release.txt"], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "release"], { cwd: repo }); + const created = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repo, worktreeSlug: "base-refresh", baseBranch: "main" }, + }); + const workspace = created.workspace; + if (!workspace) throw new Error(created.error ?? "Failed to create workspace"); + const cwd = workspace.workspaceDirectory; + execFileSync("git", ["merge", "--ff-only", "release"], { cwd }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).toContain("release.txt"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).not.toContain("release.txt"); + await expect( + ctx.client.setWorkspaceBaseBranch({ workspaceId: workspace.id, baseRef: "refs/heads/missing" }), + ).rejects.toThrow("Base branch not found"); + execFileSync("git", ["update-ref", "refs/remotes/origin/vanished", "release"], { cwd }); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/remotes/origin/vanished", + }); + execFileSync("git", ["update-ref", "-d", "refs/remotes/origin/vanished"], { cwd }); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/main", + }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).toContain("release.txt"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + const previous = ctx; + const paseoHomeRoot = path.dirname(previous.daemon.paseoHome); + await previous.client.close(); + await previous.daemon.daemon.stop(); + tempRoots.push(previous.daemon.staticDir); + ctx = await createDaemonTestContext({ paseoHomeRoot }); + expect((await ctx.client.getCheckoutStatus(cwd)).baseRefExact).toBe("refs/heads/release"); +}, 30_000); + +test("a managed workspace restores its changed base after archival removes the checkout", async () => { + const repo = createGitRepo(); + execFileSync("git", ["branch", "release"], { cwd: repo }); + const created = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repo, worktreeSlug: "changed-base", baseBranch: "main" }, + }); + const workspace = created.workspace; + if (!workspace) throw new Error(created.error ?? "Failed to create workspace"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + const joinedId = await createLocalWorkspace( + workspace.workspaceDirectory, + "Joined after changing base", + ); + expect((await ctx.client.archiveWorkspace(workspace.id)).error).toBe(null); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); + expect((await ctx.client.archiveWorkspace(joinedId)).error).toBe(null); + expect(existsSync(workspace.workspaceDirectory)).toBe(false); + await ctx.client.restoreWorkspace(joinedId); + expect((await readPaseoWorktreeMetadata(workspace.workspaceDirectory))?.baseRef).toBe( + "refs/heads/release", + ); + expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe( + "refs/heads/release", + ); +}); + +test("an archived workspace keeps its base when a later workspace reuses its path", async () => { + const repo = createGitRepo(); + execFileSync("git", ["branch", "release"], { cwd: repo }); + const source = { kind: "worktree", cwd: repo, worktreeSlug: "reused-path", baseBranch: "main" }; + const first = (await ctx.client.createWorkspace({ source })).workspace; + if (!first) throw new Error("Failed to create first workspace"); + expect((await ctx.client.archiveWorkspace(first.id)).error).toBe(null); + expect(existsSync(first.workspaceDirectory)).toBe(false); + const second = (await ctx.client.createWorkspace({ source })).workspace; + if (!second) throw new Error("Failed to create second workspace"); + expect(second.workspaceDirectory).toBe(first.workspaceDirectory); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: second.id, + baseRef: "refs/heads/release", + }); + expect((await ctx.client.archiveWorkspace(second.id)).error).toBe(null); + await ctx.client.restoreWorkspace(first.id); + expect((await readPaseoWorktreeMetadata(first.workspaceDirectory))?.baseRef).toBe( + "refs/heads/main", + ); +}); + test.each(["worktree", "workspace"] as const)( "%s archive immediately removes the deleted worktree from a warm CLI listing", async (command) => { diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index e291007db1..b6c08eb849 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -343,6 +343,21 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); + test("an errored root agent shows failed only until its error is read", async () => { + const unread = new WorkspaceStatus(); + unread.hasRootAgent({ + id: "root-agent", + status: "error", + requiresAttention: true, + attentionReason: "error", + }); + await expect(unread.workspaceStatus()).resolves.toBe("failed"); + + const read = new WorkspaceStatus(); + read.hasRootAgent({ id: "root-agent", status: "error", requiresAttention: false }); + await expect(read.workspaceStatus()).resolves.toBe("done"); + }); + test("a running background agent does not make its workspace running", async () => { const workspace = new WorkspaceStatus(); diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 26d47918b7..17e3a4a2af 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -624,7 +624,10 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro await writeFile(join(worktree, 'ignored', 'noise'), 'noise'); await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir'); const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; }); - const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') })); + // The observer hashes and coalesces paths in a Map before delivery. Reuse + // two prepared strings instead of constructing 10,000 equal-valued strings. + const noisePaths = [join(worktree, 'ignored', 'noise'), join(alias, 'ignored', 'noise')]; + const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: noisePaths[i % 2] })); let fsCalls = 0; const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } }); // Collect repository setup and event-fixture garbage before measuring @@ -648,15 +651,18 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro assert.equal(unrelatedWorkStarted, false, 'control work must not contaminate the cold filter'); hook.enable(); let blockingMs; + let elapsedMs; + let cpu; try { // This callback is synchronous: measure its own span, without waiting // for marker IO, loader messages or other event-loop callbacks. const startedAt = performance.now(); const startedCpu = threadCpuUsage(); callback(null, events); - const cpu = threadCpuUsage(startedCpu); + cpu = threadCpuUsage(startedCpu); // Cap coarse Windows CPU accounting by elapsed time; exclude descheduling. - blockingMs = Math.min(performance.now() - startedAt, (cpu.user + cpu.system) / 1000); + elapsedMs = performance.now() - startedAt; + blockingMs = Math.min(elapsedMs, (cpu.user + cpu.system) / 1000); } finally { hook.disable(); await marker('FILTER_END'); @@ -665,7 +671,7 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro assert.equal(unrelatedWorkStarted, true, 'control work must run outside the filter span'); assert.equal(fsCalls, 0, "filesystem calls"); assert.equal(notifications, 0, "ignored event notification"); - assert.ok(blockingMs < 20, 'blocking filter work: ' + blockingMs); + assert.ok(blockingMs < 20, 'blocking filter work: ' + JSON.stringify({ blockingMs, elapsedMs, cpu })); console.log(JSON.stringify({ blockingMs, fsCalls })); // Alias identity remains useful after deletion: no per-event realpath can recover this file. callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 490807f8d6..d87e896ead 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -766,6 +766,35 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("a refresh requested after a mutation waits for a new snapshot", async () => { + const gate = createDeferred(); + let reads = 0; + const service = createService({ + getCheckoutStatus: (cwd: string) => { + reads += 1; + return reads === 1 + ? gate.promise + : Promise.resolve(createCheckoutStatus(cwd, { currentBranch: "new-base" })); + }, + }); + const first = service.getSnapshot(REPO_CWD, { + force: true, + includeForge: false, + reason: "before-mutation", + }); + await vi.waitFor(() => expect(reads).toBe(1)); + const after = service.getSnapshot(REPO_CWD, { + force: true, + includeForge: false, + reason: "after-mutation", + afterInFlight: true, + }); + gate.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "old-base" })); + await first; + expect((await after).git.currentBranch).toBe("new-base"); + service.dispose(); + }); + test("bounds refresh generations and re-enqueues hot workspace successors fairly", async () => { const hotCwds = Array.from({ length: WORKSPACE_GIT_REFRESH_CONCURRENCY }, (_, index) => path.resolve(`/tmp/hot-repo-${index}`), diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 668373e998..ea91186e9b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -160,6 +160,7 @@ export interface WorkspaceGitRuntimeSnapshot { isPaseoOwnedWorktree: boolean; isDirty: boolean | null; baseRef: string | null; + baseRefExact?: string | null; aheadBehind: { ahead: number; behind: number } | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -296,6 +297,7 @@ export type WorkspaceGitReadOptions = export interface WorkspaceGitBranchSuggestionsOptions { query?: string; limit?: number; + refMode?: "exact"; } export interface WorkspaceGitStashListOptions { @@ -905,7 +907,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const normalizedCwd = resolve(cwd); const query = options?.query ?? ""; const limit = options?.limit; - const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); + const key = JSON.stringify([ + "branch-suggestions", + normalizedCwd, + query, + limit ?? null, + options?.refMode, + ]); return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => this.deps.listBranchSuggestions(normalizedCwd, options), ); @@ -3573,6 +3581,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const cwd = target.cwd; const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target); + const previousBaseRef = target.latestFacts?.isGit ? target.latestFacts.resolvedBaseRef : null; const baseContext: CheckoutContext = { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, @@ -3580,6 +3589,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { runGitCommand: runRefreshGitCommand, }; const facts = await this.loadCheckoutFacts(target, baseContext); + if (facts.isGit && facts.resolvedBaseRef !== previousBaseRef) { + this.invalidateCheckoutDiffCache(cwd, "base"); + this.invalidateCheckoutDiffCache(facts.worktreeRoot, "base"); + } const context: CheckoutContext = { ...baseContext, facts }; const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); if (!checkoutStatus.isGit) { @@ -3612,6 +3625,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ? checkoutStatus.isDirty : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, + baseRefExact: checkoutStatus.baseRefExact, aheadBehind: checkoutStatus.aheadBehind, upstreamRef: checkoutStatus.upstreamRef, aheadOfOrigin: checkoutStatus.aheadOfOrigin, diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 181ec96eae..3170281d60 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -108,7 +108,7 @@ export function initialWorkspacePlacement( /** * Applies live placement facts without rewriting the workspace's durable name - * or its creation-time base branch. + * or its selected base branch. * * A null checkout is an absence, not an observation: nothing was read at the * workspace directory, so the recorded placement stands. Only a directory that diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index a0829251cc..fd376cc14c 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -50,6 +50,7 @@ const PersistedProjectRecordSchema = z.object({ }); const PersistedWorkspaceRecordSchema = z.object({ + parentWorkspaceId: z.string().optional(), workspaceId: z.string(), projectId: z.string(), cwd: z.string(), @@ -76,9 +77,9 @@ const PersistedWorkspaceRecordSchema = z.object({ // archive and recovery do not need the directory to still exist in order to // recover placement. worktreeRoot: z.string().nullable().default(null), - // Comparison base preserved across worktree deletion and restore. New branch-off + // Current base preserved across worktree deletion and restore. New branch-off // workspaces store the resolved ref (e.g. refs/remotes/upstream/main); legacy and - // PR checkout records may contain a bare branch name. Ordinary checkouts use null. + // PR checkout records may contain a bare branch name. Null means no recorded base. baseBranch: z .string() .nullable() @@ -682,6 +683,7 @@ export function resolveProjectDisplayName(record: PersistedProjectRecord): strin } export function createPersistedWorkspaceRecord(input: { + parentWorkspaceId?: string; workspaceId: string; projectId: string; cwd: string; diff --git a/packages/server/src/terminal/worker-terminal-manager.test.ts b/packages/server/src/terminal/worker-terminal-manager.test.ts index 2a584087a3..0bcc2acf92 100644 --- a/packages/server/src/terminal/worker-terminal-manager.test.ts +++ b/packages/server/src/terminal/worker-terminal-manager.test.ts @@ -70,7 +70,7 @@ import { TerminalWorkerIncompatibleError, } from "./terminal-worker-pool.js"; import { connectTerminalWorker } from "./terminal-worker-client.js"; -import { launchTerminalWorker } from "./terminal-worker-launch.js"; +import { launchTerminalWorker, TerminalWorkerLaunchError } from "./terminal-worker-launch.js"; type TerminalRow = TerminalState["grid"][number]; @@ -2671,3 +2671,34 @@ it.each([false, true])( }, 45000, ); + +// Both Windows reports in #8766: the worker's process identity query (PowerShell there, `ps` here) +// fails, the worker exits before writing its record, and the daemon used to exit with it. +it.skipIf(isPlatform("win32"))( + "starts without a worker when the worker cannot read its own identity, and launches one once it can", + async () => { + const paseoHome = createTemporaryHome(); + const binDir = mkdtempSync(join(tmpdir(), "worker-terminal-manager-no-ps-")); + temporaryDirs.push(binDir); + writeFileSync(join(binDir, "ps"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + const originalPath = process.env.PATH; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + try { + manager = await startRealWorkerManager({ paseoHome }); + expect(await workerRecords(paseoHome)).toEqual([]); + await expect( + manager.createTerminal({ workspaceId: "ws-no-identity", cwd: paseoHome }), + ).rejects.toThrow(TerminalWorkerLaunchError); + expect(await workerRecords(paseoHome)).toEqual([]); + } finally { + process.env.PATH = originalPath; + } + + const terminal = trackTerminal( + await manager.createTerminal({ workspaceId: "ws-no-identity", cwd: paseoHome }), + ); + expect(terminal.id).toBeTruthy(); + expect((await workerRecords(paseoHome)).map((record) => record.pid)).toHaveLength(1); + }, + 30000, +); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 9a82826cec..ea91d54a66 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -1269,8 +1269,17 @@ export async function startWorkerTerminalManager( try { current = await startCurrent(); } catch (error) { - if (!(error instanceof NoTerminalWorkerAvailableError)) throw error; - logger.warn(error.message); + if (error instanceof NoTerminalWorkerAvailableError) { + logger.warn(error.message); + } else { + // The daemon starts without terminals rather than not at all. A worker that cannot start + // here (its process identity query failed, say) is launched again by the first terminal + // creation, which reports the same error if it still cannot. + logger.error( + { err: error }, + "Terminal worker did not start; terminals are unavailable until one starts", + ); + } } } diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index bd52b0e49f..075f7ebda1 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -32,6 +32,7 @@ import { getCheckoutStatus, checkoutResolvedBranch, listBranchSuggestions, + setCheckoutBaseBranch, mergeToBase, mergeFromBase, MergeConflictError, @@ -263,6 +264,56 @@ describe("checkout git utilities", () => { rmSync(tempDir, { recursive: true, force: true }); }); + it("rejects changing the base of a local checkout without creating metadata", async () => { + await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" })).rejects.toThrow(); + expect(existsSync(await getPaseoWorktreeMetadataPath(repoDir))).toBe(false); + }); + + it("changing a managed worktree base preserves its runtime and PR identity", async () => { + const worktree = await createLegacyWorktreeForTest({ + cwd: repoDir, + branchName: "feature/base", + baseBranch: "main", + worktreeSlug: "base", + paseoHome, + runSetup: false, + }); + const original = await readPaseoWorktreeMetadata(worktree.worktreePath); + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + await setCheckoutBaseBranch({ + cwd: worktree.worktreePath, + baseRef: "release", + context: { paseoHome }, + }); + expect(await readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({ + ...original, + baseRefName: "release", + baseRef: "refs/heads/release", + }); + expect(await getCheckoutSnapshotFacts(worktree.worktreePath, { paseoHome })).toMatchObject({ + storedBaseRef: "refs/heads/release", + }); + }); + + it("lists exact local and all remote refs separately for the base picker", async () => { + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + execFileSync("git", ["update-ref", "refs/remotes/origin/release", "HEAD"], { cwd: repoDir }); + execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "HEAD"], { cwd: repoDir }); + const suggestions = await listBranchSuggestions(repoDir, { + refMode: "exact", + query: "release", + }); + expect(suggestions.map((entry) => entry.name).sort()).toEqual([ + "refs/heads/release", + "refs/remotes/origin/release", + "refs/remotes/upstream/release", + ]); + expect(suggestions.find((entry) => entry.name === "refs/heads/release")).toMatchObject({ + hasLocal: true, + hasRemote: false, + }); + }); + it("throws NotGitRepoError for non-git directories", async () => { const nonGitDir = join(tempDir, "not-git"); mkdirSync(nonGitDir, { recursive: true }); @@ -1920,6 +1971,13 @@ const x = 1; expect(status.currentBranch).toBe("feature/plain"); }); + it("rejects merging a branch into its own same-named remote base", async () => { + execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir }); + await expect(mergeToBase(repoDir, { baseRef: "refs/remotes/origin/main" })).rejects.toThrow( + "Cannot merge a branch into itself", + ); + }); + it("merges the current branch into base from a worktree checkout", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "main", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index a0cbaa7afc..d793e2c683 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -51,6 +51,7 @@ import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata, rebindPaseoWorktreeChangeRequestHint, + setPaseoWorktreeBaseRef, } from "./worktree-metadata.js"; const READ_ONLY_GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", @@ -323,15 +324,30 @@ function sortBranchSuggestions( export async function listBranchSuggestions( cwd: string, - options?: { query?: string; limit?: number }, + options: { query?: string; limit?: number; refMode?: "exact" } = {}, ): Promise { await requireGitRepo(cwd); - const requestedLimit = options?.limit ?? 50; + const requestedLimit = options.limit ?? 50; const limit = Math.max(1, Math.min(200, requestedLimit)); - const rawQuery = options?.query?.trim().toLowerCase() ?? ""; + const rawQuery = options.query?.trim().toLowerCase() ?? ""; const query = normalizeBranchSuggestionName(rawQuery) ?? rawQuery; + if (options.refMode === "exact") { + const refs = await listGitRefs(cwd, "refs"); + const branches = refs.filter((ref) => { + const isBranch = ref.name.startsWith("refs/heads/") || ref.name.startsWith("refs/remotes/"); + return isBranch && !ref.name.endsWith("/HEAD") && ref.name.toLowerCase().includes(rawQuery); + }); + branches.sort((a, b) => b.committerDate - a.committerDate || a.name.localeCompare(b.name)); + return branches.slice(0, limit).map((ref) => ({ + name: ref.name, + committerDate: ref.committerDate, + hasLocal: ref.name.startsWith("refs/heads/"), + hasRemote: ref.name.startsWith("refs/remotes/"), + })); + } + const [localRefs, remoteRefs] = await Promise.all([ listGitRefs(cwd, "refs/heads"), listGitRefs(cwd, "refs/remotes/origin"), @@ -889,6 +905,7 @@ export interface CheckoutStatusGitNonPaseo { currentBranch: string | null; isDirty: boolean; baseRef: string | null; + baseRefExact?: string | null; aheadBehind: AheadBehind | null; // Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the // branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are @@ -908,6 +925,7 @@ export interface CheckoutStatusGitPaseo { currentBranch: string | null; isDirty: boolean; baseRef: string; + baseRefExact?: string | null; aheadBehind: AheadBehind | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -1308,12 +1326,36 @@ async function getStoredBaseRefForCwd( if (context?.facts?.isGit) { return context.facts.storedBaseRef; } - const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context }); - if (!paseoWorktree.isPaseoOwnedWorktree) { - return null; - } + const ownership = await getPaseoWorktreeForCwd(cwd, { context }); + return ownership.isPaseoOwnedWorktree ? readPaseoWorktreeBaseRef(ownership.worktreeRoot) : null; +} - return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot); +export async function setCheckoutBaseBranch(input: { + cwd: string; + baseRef: string; + context?: CheckoutContext; +}): Promise { + await requireGitRepo(input.cwd); + const ownership = await getPaseoWorktreeForCwd(input.cwd, { context: input.context }); + if (!ownership.isPaseoOwnedWorktree) throw new Error("Workspace is not a Paseo worktree"); + const requested = input.baseRef.trim(); + const qualified = requested.startsWith("refs/"); + const candidates = qualified + ? [requested] + : [`refs/heads/${requested}`, `refs/remotes/${requested}`, `refs/remotes/origin/${requested}`]; + for (const candidate of candidates) { + if (!isQualifiedBranchRef(candidate) || candidate.endsWith("/HEAD")) continue; + const valid = await runGitCommand(["check-ref-format", candidate], { + cwd: input.cwd, + acceptExitCodes: [0, 1], + }); + if (valid.exitCode !== 0 || !(await doesGitRefExist(input.cwd, candidate))) continue; + const worktreeRoot = await getWorktreeRoot(input.cwd); + if (!worktreeRoot) throw new NotGitRepoError(input.cwd); + await setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate }); + return candidate; + } + throw new Error(`Base branch not found: ${requested}`); } async function getResolvedBaseRefForCwd( @@ -1786,7 +1828,11 @@ async function getAheadBehind( context?: CheckoutContext, ): Promise { const normalizedBaseRef = branchNameFromRef(baseRef); - if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) { + if ( + !normalizedBaseRef || + !currentBranch || + (!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch) + ) { return null; } const comparisonBaseRef = @@ -2128,7 +2174,8 @@ export async function getCheckoutSnapshotFacts( if ( resolvedBaseRef && inspected.currentBranch && - branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch + (isQualifiedBranchRef(resolvedBaseRef) || + branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch) ) { comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch( () => null, @@ -2373,6 +2420,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef ?? baseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2391,6 +2439,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2605,7 +2654,10 @@ async function tryResolveCheckoutCommitsBaseRef( return null; } const normalizedBaseRef = branchNameFromRef(baseRef); - if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { + if ( + !normalizedBaseRef || + (!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch) + ) { return null; } try { @@ -3890,7 +3942,7 @@ export async function mergeToBase( const normalizedBaseRef = resolveMergeTargetBranch(baseRef); const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd; if (normalizedBaseRef === currentBranch) { - return currentWorktreeRoot; + throw new Error("Cannot merge a branch into itself"); } const baseWorktree = await getWorktreePathForBranch(cwd, normalizedBaseRef); diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index fcb0454672..fc4488d742 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -14,7 +14,7 @@ const ChangeRequestLookupTargetSchema = z.object({ localBranchName: z.string().min(1).optional(), }); -// baseRefName is the display name; baseRef is the exact ref the worktree was cut from +// baseRefName is the display name; baseRef is the checkout's current exact base // ("refs/remotes/upstream/main"). baseRef is optional because worktrees written before it // existed only have the name — there are no migrations, so readers fall back. const PaseoWorktreeMetadataV1Schema = z.object({ @@ -249,6 +249,21 @@ export async function writePaseoWorktreeMetadata( }); } +export async function setPaseoWorktreeBaseRef(input: { + worktreeRoot: string; + baseRef: string; +}): Promise { + return mutateMetadata(input.worktreeRoot, async (root) => { + const current = await readMetadataFile(root); + if (!current) throw new Error("Cannot change base: missing worktree metadata"); + await writePaseoWorktreeMetadataFile(root, { + ...current, + baseRefName: branchNameFromRef(input.baseRef), + baseRef: input.baseRef, + }); + }); +} + export async function writePaseoWorktreeRuntimeMetadata( worktreeRoot: string, options: { worktreePort: number }, diff --git a/packages/website/package.json b/packages/website/package.json index 9554c5a8f9..696ebb8a51 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/website", - "version": "0.15.0", + "version": "0.15.2", "private": true, "type": "module", "scripts": { diff --git a/patches/@legendapp+list+3.7.1.patch b/patches/@legendapp+list+3.7.1.patch new file mode 100644 index 0000000000..3a59c09550 --- /dev/null +++ b/patches/@legendapp+list+3.7.1.patch @@ -0,0 +1,548 @@ +# LegendList 3.7.1 for the native chat timeline (docs/agent-stream-disclosure.md). +# Upstream: https://github.com/LegendApp/legend-list/issues/593 covers every change below. +# react-native.js and react-native.mjs carry the same hunks; each has a "Paseo patch" comment. +# +# 1. getEstimatedItemSize (react-native.d.ts, getItemSize, LegendListInner props): a per-item +# estimate for an unmeasured row wins over the running average for its type. +# 2. MVCP anchor lock on native (canRestoreAnchorLock, resolveAnchorLock, updateAnchorLock, +# prepareMVCP): upstream enables it on web only. On native the lock ends when its row leaves +# the view or shouldRestorePosition rejects it, instead of after 300 ms. +# 3. Preserved initial end only with maintainScrollAtEnd (finishScrollTo, +# finishBootstrapInitialScrollWithoutScroll): otherwise every data change re-arms the initial +# scroll to the end. +# 4. Silent initial scroll retry in one task (checkFinishedScrollFallback): the nudge and the return +# to the target go out together, so Android does not reveal 1 dp short of the end. +# 5. Layout noise against the floored known size (isNativeLayoutNoise, updateOneItemSize): a +# one-pixel snap change is not a resize. +# 6. Content height from React state on the New Architecture (useContentSize, ContainersLayer): +# the Animated height mounts the estimated layout for a frame. +# 7. Header and footer revealed with the rows (RevealedLayoutView, ListComponent). +# 8. Top-padding shrink hold on iOS only (setPaddingTop): Android paints the held height as blank +# space at the end. +# 9. Rows cut off at the top count as in view (getIdsInVisibleRange): they follow the fully +# visible rows in idsInView, so a disclosure hold on a row whose top is above the viewport +# still anchors on that row instead of the row below it. +# 10. A size change keeps only a row shouldRestorePosition accepts in place (prepareMVCP), as a +# data change already does, so a disclosure that only grows (a todo card) holds the cut-off +# row instead of the first row in view. +# +# Upgrading: drop each hunk whose fix the new version ships, regenerate with +# `npx patch-package @legendapp/list`, and copy this header back on top. +diff --git a/node_modules/@legendapp/list/react-native.d.ts b/node_modules/@legendapp/list/react-native.d.ts +index 983e937..970ba51 100644 +--- a/node_modules/@legendapp/list/react-native.d.ts ++++ b/node_modules/@legendapp/list/react-native.d.ts +@@ -204,6 +204,11 @@ interface LegendListSpecificProps { + * Change extraData when the fixed sizes returned by this callback change. + */ + getFixedItemSize?: (item: ItemT, index: number, type: TItemType) => number | undefined; ++ /** ++ * Paseo patch: estimated size of an item that has not been measured yet. Return undefined to ++ * fall back to the average size for its type. ++ */ ++ getEstimatedItemSize?: (item: ItemT, index: number, type: TItemType) => number | undefined; + /** + * Returns a stable item type used for pooling and size estimation. + */ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index eeb3fb0..b1689ad 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -1497,7 +1497,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no + return renderedSize; + } + const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { ++ // Paseo patch: a caller estimate for an unmeasured item wins over the running average for its ++ // type, so a row new to the list can be laid out near its real size before it is measured. ++ if (state.props.getEstimatedItemSize) { ++ const estimate = state.props.getEstimatedItemSize(data, index, itemType); ++ if (estimate !== void 0) { ++ size = estimate + ctx.scrollAxisGap; ++ } ++ } ++ if (size === void 0 && useAverageSize && !scrollingTo) { + const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; + if (averageSizeForType !== void 0) { + size = roundSize(averageSizeForType); +@@ -1570,6 +1578,12 @@ var MVCP_POSITION_EPSILON = 0.1; + var MVCP_ANCHOR_LOCK_TTL_MS = 300; + var MVCP_ANCHOR_LOCK_QUIET_PASSES_TO_RELEASE = 2; + var NATIVE_END_CLAMP_EPSILON = 1; ++function canRestoreAnchorLock(state, id) { ++ const shouldRestorePosition = state.props.maintainVisibleContentPosition.shouldRestorePosition; ++ const index = state.indexByKey.get(id); ++ const data = state.props.data; ++ return !shouldRestorePosition || index !== void 0 && data[index] !== void 0 && shouldRestorePosition(data[index], index, data); ++} + function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!enableMVCPAnchorLock) { + state.mvcpAnchorLock = void 0; +@@ -1579,7 +1593,13 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!lock) { + return void 0; + } +- const isExpired = now > lock.expiresAt; ++ // Paseo patch: native keeps the anchor of a data change through the size passes that follow, as ++ // web does. Upstream limits the lock to web (d24a9d93 "feat: (web) add MVCP anchor lock") without a ++ // native reason. Without it, the pass that applies the measured sizes of prepended rows re-anchors ++ // on the first row in view, which can be one of those rows, and its growth moves the rows below. ++ // A wall-clock expiry is wrong on native, where the measurements arrive after the native mount ++ // and a slow render; the lock ends when its row leaves the view or may not be restored instead. ++ const isExpired = Platform.OS === "web" ? now > lock.expiresAt : !state.idsInView.includes(lock.id) || !canRestoreAnchorLock(state, lock.id); + const isMissing = state.indexByKey.get(lock.id) === void 0; + if (isExpired || isMissing || !mvcpData) { + state.mvcpAnchorLock = void 0; +@@ -1588,7 +1608,7 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + return lock; + } + function updateAnchorLock(state, params) { +- if (Platform.OS === "web") { ++ { + const { anchorId, anchorPosition, dataChanged, now, positionDiff } = params; + const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const mvcpData = state.props.maintainVisibleContentPosition.data; +@@ -1713,7 +1733,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } = props; + const isWeb = Platform.OS === "web"; + const now = Date.now(); +- const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); ++ const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const scrollingTo = state.scrollingTo; + if ((scrollingTo == null ? void 0 : scrollingTo.key) !== void 0) { + const nextIndex = state.indexByKey.get(scrollingTo.key); +@@ -1723,7 +1743,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + state.mvcpAnchorLock = void 0; + return void 0; + } +- const anchorLock = isWeb ? resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) : void 0; ++ const anchorLock = resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now); + let prevPosition; + let targetId; + const idsInViewWithPositions = []; +@@ -1755,7 +1775,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ // Paseo patch: a size change keeps only a row shouldRestorePosition accepts in place, as a data ++ // change does. Upstream keeps the first row in view, so a disclosure hold on a row cut off at ++ // the top kept the row below it instead and moved the trigger by the row's size change. ++ targetId = idsInView.find((id) => indexByKey.get(id) !== void 0 && canRestoreAnchorLock(state, id)); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +@@ -2371,7 +2394,10 @@ function finishScrollTo(ctx) { + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { + const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ // Paseo patch: only a list that keeps its end keeps landing on the initial end after the ++ // initial scroll. Without maintainScrollAtEnd, the preserved target re-arms the initial scroll ++ // on every data change, footer layout and viewport resize, and a stream never lets it clear. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2515,11 +2541,12 @@ function checkFinishedScrollFallback(ctx) { + const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0; + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); ++ // Paseo patch: send the nudge and the return to the target in one task, so native applies both before ++ // it draws and the pair still emits the scroll event this retry exists for. Upstream returns on the next ++ // frame, but the 16 ms check below can run first, finish the scroll and cancel that frame; the list then ++ // revealed 1 dp short of its end and moved into place a frame later. + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ scrollToFallbackOffset(ctx, targetOffset); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +@@ -3378,7 +3405,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { + var _a3; + const state = ctx.state; + clearBootstrapInitialScrollSession(state); +- const shouldPreserveResizeTarget = !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; ++ // Paseo patch: see finishScrollTo; only a list that keeps its end preserves the initial end. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; + finishInitialScroll(ctx, { + preserveTarget: shouldPreserveResizeTarget, + recalculateItems: true, +@@ -4569,6 +4597,17 @@ function getIdsInVisibleRange(state, range) { + const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); + idsInView.push(id); + } ++ // Paseo patch: a row whose top is above the viewport is still in view. Upstream starts the ids at ++ // the first fully visible row, so a disclosure hold on a row cut off at the top finds no anchor ++ // (shouldRestorePosition rejects every other row) and the next size pass keeps the row below it ++ // in place instead. The partly visible rows go last, so the first fully visible row stays the ++ // anchor of every pass that does not restrict it. ++ if (range.startNoBuffer !== null && range.startNoBuffer < firstVisibleAnchorIndex) { ++ for (let i = range.startNoBuffer; i < firstVisibleAnchorIndex; i++) { ++ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); ++ idsInView.push(id); ++ } ++ } + } + return idsInView; + } +@@ -5176,8 +5215,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL + function isWithinEpsilon(delta) { + return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON; + } +-function isNativeLayoutNoise(delta) { +- return isWithinEpsilon(delta); ++// Paseo patch: sizesKnown holds roundSize values, floored to 1/8 dp, so the raw size behind a known ++// size lies in [known, known + 1/8). Upstream compares the raw size with the floored one, so a row ++// whose pixel-snapped height changes by one pixel, plus the floor, reads as a resize. On a cold open ++// that late resize moved every row a frame after the reveal. ++function isNativeLayoutNoise(rawSize, knownSize) { ++ const delta = rawSize - knownSize; ++ return delta >= -NATIVE_LAYOUT_MEASUREMENT_EPSILON && delta < NATIVE_LAYOUT_MEASUREMENT_EPSILON + 1 / 8; + } + + // src/core/updateItemSizes.ts +@@ -5414,7 +5458,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif + state.fixedItemSizeKeys.add(itemKey); + } + const prevSizeKnown = sizesKnown.get(itemKey); +- if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize - prevSizeKnown)) { ++ if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize, prevSizeKnown)) { + return 0; + } + const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); +@@ -6283,6 +6327,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + + // src/components/Containers.native.tsx + var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; ++// Paseo patch: on the new architecture the content height comes from React state only. The ++// JS-driven Animated height writes through setNativeProps, which Fabric commits synchronously ++// mid-task, mounting React's estimated layout for a frame before the measured one. ++var useContentSize = IsNewArchitecture ? useCommittedMinSize : () => useValue$("totalSize"); + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6290,7 +6338,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const size = useValue$("totalSize"); ++ const size = useContentSize(); + const minSize = useCommittedMinSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); +@@ -6513,6 +6561,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont + null + ); + }); ++// Paseo patch: the header and footer reveal with the rows. Upstream hides only the containers until ++// readyToRender, so a cold open paints the header and footer at their estimated place a frame or more ++// before the rows appear around them. ++var RevealedLayoutView = function RevealedLayoutView2({ freshDataTransitionEpoch, style, ...rest }) { ++ const [readyToRender] = useArr$(["readyToRender"]); ++ const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); ++ return /* @__PURE__ */ React2__namespace.createElement(LayoutView, { ...rest, pointerEvents: isVisible ? "auto" : "none", style: isVisible ? style : [style, { opacity: 0 }] }); ++}; + var ListComponent = typedMemo(function ListComponent2({ + canRender, + freshDataTransitionEpoch, +@@ -6651,7 +6707,7 @@ var ListComponent = typedMemo(function ListComponent2({ + style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style + }, + /* @__PURE__ */ React2__namespace.createElement(ScrollAdjust, null), +- ListHeaderComponent && /* @__PURE__ */ React2__namespace.createElement(LayoutView, { onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), ++ ListHeaderComponent && /* @__PURE__ */ React2__namespace.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), + ListEmptyComponent && getComponent(ListEmptyComponent), + shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2__namespace.createElement(AlignItemsAtEndSpacer, { horizontal }), + canRender && !ListEmptyComponent && /* @__PURE__ */ React2__namespace.createElement( +@@ -6665,7 +6721,7 @@ var ListComponent = typedMemo(function ListComponent2({ + stickyHeaderConfig + } + ), +- ListFooterComponent && /* @__PURE__ */ React2__namespace.createElement(LayoutView, { onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), ++ ListFooterComponent && /* @__PURE__ */ React2__namespace.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), + IS_DEV && ENABLE_DEVMODE + ); + }); +@@ -7886,7 +7942,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { + const state = ctx.state; + if (stylePaddingTop !== void 0) { + const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; +- if (stylePaddingTop < prevStylePaddingTop) { ++ // Paseo patch: only iOS compensates a shrinking top padding with a scroll adjust, which this ++ // keeps the native clamp from doubling. Elsewhere the held height paints as blank space at the ++ // end until the timer fires, and a scroll view that keeps its end scrolls into it. ++ if (stylePaddingTop < prevStylePaddingTop && Platform.OS === "ios") { + let prevTotalSize = peek$(ctx, "totalSize") || 0; + set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); + state.timeoutSetPaddingTop = setTimeout(() => { +@@ -7989,6 +8048,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + estimatedListSize, + extraData, + getFixedItemSize, ++ getEstimatedItemSize, + getItemType, + horizontal, + rtl, +@@ -8282,6 +8342,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance, + estimatedItemSize, ++ getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize), + getFixedItemSize: useWrapIfItem(getFixedItemSize), + getItemType: useWrapIfItem(getItemType), + hasExternalScroll, +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index fe5f415..e61945e 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -1476,7 +1476,15 @@ function getItemSize(ctx, key, index, data, useAverageSize, preferCachedSize, no + return renderedSize; + } + const itemType = (_b = resolved == null ? void 0 : resolved.itemType) != null ? _b : getItemType ? (_a3 = getItemType(data, index)) != null ? _a3 : "" : ""; +- if (useAverageSize && !scrollingTo) { ++ // Paseo patch: a caller estimate for an unmeasured item wins over the running average for its ++ // type, so a row new to the list can be laid out near its real size before it is measured. ++ if (state.props.getEstimatedItemSize) { ++ const estimate = state.props.getEstimatedItemSize(data, index, itemType); ++ if (estimate !== void 0) { ++ size = estimate + ctx.scrollAxisGap; ++ } ++ } ++ if (size === void 0 && useAverageSize && !scrollingTo) { + const averageSizeForType = (_c = averageSizes[itemType]) == null ? void 0 : _c.avg; + if (averageSizeForType !== void 0) { + size = roundSize(averageSizeForType); +@@ -1549,6 +1557,12 @@ var MVCP_POSITION_EPSILON = 0.1; + var MVCP_ANCHOR_LOCK_TTL_MS = 300; + var MVCP_ANCHOR_LOCK_QUIET_PASSES_TO_RELEASE = 2; + var NATIVE_END_CLAMP_EPSILON = 1; ++function canRestoreAnchorLock(state, id) { ++ const shouldRestorePosition = state.props.maintainVisibleContentPosition.shouldRestorePosition; ++ const index = state.indexByKey.get(id); ++ const data = state.props.data; ++ return !shouldRestorePosition || index !== void 0 && data[index] !== void 0 && shouldRestorePosition(data[index], index, data); ++} + function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!enableMVCPAnchorLock) { + state.mvcpAnchorLock = void 0; +@@ -1558,7 +1572,13 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + if (!lock) { + return void 0; + } +- const isExpired = now > lock.expiresAt; ++ // Paseo patch: native keeps the anchor of a data change through the size passes that follow, as ++ // web does. Upstream limits the lock to web (d24a9d93 "feat: (web) add MVCP anchor lock") without a ++ // native reason. Without it, the pass that applies the measured sizes of prepended rows re-anchors ++ // on the first row in view, which can be one of those rows, and its growth moves the rows below. ++ // A wall-clock expiry is wrong on native, where the measurements arrive after the native mount ++ // and a slow render; the lock ends when its row leaves the view or may not be restored instead. ++ const isExpired = Platform.OS === "web" ? now > lock.expiresAt : !state.idsInView.includes(lock.id) || !canRestoreAnchorLock(state, lock.id); + const isMissing = state.indexByKey.get(lock.id) === void 0; + if (isExpired || isMissing || !mvcpData) { + state.mvcpAnchorLock = void 0; +@@ -1567,7 +1587,7 @@ function resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) { + return lock; + } + function updateAnchorLock(state, params) { +- if (Platform.OS === "web") { ++ { + const { anchorId, anchorPosition, dataChanged, now, positionDiff } = params; + const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const mvcpData = state.props.maintainVisibleContentPosition.data; +@@ -1692,7 +1712,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } = props; + const isWeb = Platform.OS === "web"; + const now = Date.now(); +- const enableMVCPAnchorLock = isWeb && (!!dataChanged || !!state.mvcpAnchorLock); ++ const enableMVCPAnchorLock = !!dataChanged || !!state.mvcpAnchorLock; + const scrollingTo = state.scrollingTo; + if ((scrollingTo == null ? void 0 : scrollingTo.key) !== void 0) { + const nextIndex = state.indexByKey.get(scrollingTo.key); +@@ -1702,7 +1722,7 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + state.mvcpAnchorLock = void 0; + return void 0; + } +- const anchorLock = isWeb ? resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now) : void 0; ++ const anchorLock = resolveAnchorLock(state, enableMVCPAnchorLock, mvcpData, now); + let prevPosition; + let targetId; + const idsInViewWithPositions = []; +@@ -1734,7 +1754,10 @@ function prepareMVCP(ctx, dataChanged, adjustmentSource) { + } + targetId = (_b = scrollingTo == null ? void 0 : scrollingTo.key) != null ? _b : getId(state, scrollTarget); + } else if (idsInView.length > 0 && state.didContainersLayout && !dataChanged) { +- targetId = idsInView.find((id) => indexByKey.get(id) !== void 0); ++ // Paseo patch: a size change keeps only a row shouldRestorePosition accepts in place, as a data ++ // change does. Upstream keeps the first row in view, so a disclosure hold on a row cut off at ++ // the top kept the row below it instead and moved the trigger by the row's size change. ++ targetId = idsInView.find((id) => indexByKey.get(id) !== void 0 && canRestoreAnchorLock(state, id)); + } + if (dataChanged && idsInView.length > 0 && state.didContainersLayout) { + for (let i = 0; i < idsInView.length; i++) { +@@ -2350,7 +2373,10 @@ function finishScrollTo(ctx) { + }; + if (scrollingTo.isInitialScroll || state.initialScroll) { + const isOffsetSession = ((_b = state.initialScrollSession) == null ? void 0 : _b.kind) === "offset"; +- const shouldPreserveResizeTarget = !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; ++ // Paseo patch: only a list that keeps its end keeps landing on the initial end after the ++ // initial scroll. Without maintainScrollAtEnd, the preserved target re-arms the initial scroll ++ // on every data change, footer layout and viewport resize, and a stream never lets it clear. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !!scrollingTo.isInitialScroll && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_c = state.initialScroll) == null ? void 0 : _c.viewPosition) === 1; + finishInitialScroll(ctx, { + onFinished: complete, + preserveTarget: isOffsetSession && state.props.data.length === 0 || shouldPreserveResizeTarget, +@@ -2494,11 +2520,12 @@ function checkFinishedScrollFallback(ctx) { + const targetOffset = (_d = (_c = (_b = initialScrollWatchdog.get(state)) == null ? void 0 : _b.targetOffset) != null ? _c : isStillScrollingTo.targetOffset) != null ? _d : 0; + const jiggleOffset = targetOffset >= SILENT_INITIAL_SCROLL_TARGET_EPSILON ? targetOffset - SILENT_INITIAL_SCROLL_TARGET_EPSILON : targetOffset + SILENT_INITIAL_SCROLL_TARGET_EPSILON; + initialScrollCompletion.markSilentInitialScrollRetry(state); ++ // Paseo patch: send the nudge and the return to the target in one task, so native applies both before ++ // it draws and the pair still emits the scroll event this retry exists for. Upstream returns on the next ++ // frame, but the 16 ms check below can run first, finish the scroll and cancel that frame; the list then ++ // revealed 1 dp short of its end and moved into place a frame later. + scrollToFallbackOffset(ctx, jiggleOffset); +- state.scheduledWork.frame( +- () => scrollToFallbackOffset(ctx, targetOffset), +- "checkFinishedScrollRetryFrame" +- ); ++ scrollToFallbackOffset(ctx, targetOffset); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); +@@ -3357,7 +3384,8 @@ function finishBootstrapInitialScrollWithoutScroll(ctx, resolvedOffset) { + var _a3; + const state = ctx.state; + clearBootstrapInitialScrollSession(state); +- const shouldPreserveResizeTarget = !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; ++ // Paseo patch: see finishScrollTo; only a list that keeps its end preserves the initial end. ++ const shouldPreserveResizeTarget = !!state.props.maintainScrollAtEnd && !state.clearPreservedInitialScrollOnNextFinish && state.props.data.length > 0 && ((_a3 = state.initialScroll) == null ? void 0 : _a3.viewPosition) === 1; + finishInitialScroll(ctx, { + preserveTarget: shouldPreserveResizeTarget, + recalculateItems: true, +@@ -4548,6 +4576,17 @@ function getIdsInVisibleRange(state, range) { + const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); + idsInView.push(id); + } ++ // Paseo patch: a row whose top is above the viewport is still in view. Upstream starts the ids at ++ // the first fully visible row, so a disclosure hold on a row cut off at the top finds no anchor ++ // (shouldRestorePosition rejects every other row) and the next size pass keeps the row below it ++ // in place instead. The partly visible rows go last, so the first fully visible row stays the ++ // anchor of every pass that does not restrict it. ++ if (range.startNoBuffer !== null && range.startNoBuffer < firstVisibleAnchorIndex) { ++ for (let i = range.startNoBuffer; i < firstVisibleAnchorIndex; i++) { ++ const id = (_b = state.idCache[i]) != null ? _b : getId(state, i); ++ idsInView.push(id); ++ } ++ } + } + return idsInView; + } +@@ -5155,8 +5194,13 @@ var NATIVE_LAYOUT_MEASUREMENT_EPSILON = 1 / PixelRatio.get() + FLOATING_POINT_SL + function isWithinEpsilon(delta) { + return Math.abs(delta) <= NATIVE_LAYOUT_MEASUREMENT_EPSILON; + } +-function isNativeLayoutNoise(delta) { +- return isWithinEpsilon(delta); ++// Paseo patch: sizesKnown holds roundSize values, floored to 1/8 dp, so the raw size behind a known ++// size lies in [known, known + 1/8). Upstream compares the raw size with the floored one, so a row ++// whose pixel-snapped height changes by one pixel, plus the floor, reads as a resize. On a cold open ++// that late resize moved every row a frame after the reveal. ++function isNativeLayoutNoise(rawSize, knownSize) { ++ const delta = rawSize - knownSize; ++ return delta >= -NATIVE_LAYOUT_MEASUREMENT_EPSILON && delta < NATIVE_LAYOUT_MEASUREMENT_EPSILON + 1 / 8; + } + + // src/core/updateItemSizes.ts +@@ -5393,7 +5437,7 @@ function updateOneItemSize(ctx, itemKey, sizeObj, resolvedMeasurementItem, notif + state.fixedItemSizeKeys.add(itemKey); + } + const prevSizeKnown = sizesKnown.get(itemKey); +- if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize - prevSizeKnown)) { ++ if (Platform.OS !== "web" && prevSizeKnown !== void 0 && isNativeLayoutNoise(rawSize, prevSizeKnown)) { + return 0; + } + const size = Platform.OS === "web" ? Math.round(rawSize) : roundSize(rawSize); +@@ -6262,6 +6306,10 @@ function useFreshDataTransitionVisibility(readyToRender, transitionEpoch) { + + // src/components/Containers.native.tsx + var useCommittedMinSize = IsNewArchitecture ? () => useArr$(["totalSize"])[0] : () => void 0; ++// Paseo patch: on the new architecture the content height comes from React state only. The ++// JS-driven Animated height writes through setNativeProps, which Fabric commits synchronously ++// mid-task, mounting React's estimated layout for a frame before the measured one. ++var useContentSize = IsNewArchitecture ? useCommittedMinSize : () => useValue$("totalSize"); + var ContainersLayer = typedMemo(function ContainersLayer2({ + children, + freshDataTransitionEpoch, +@@ -6269,7 +6317,7 @@ var ContainersLayer = typedMemo(function ContainersLayer2({ + }) { + const ctx = useStateContext(); + const columnWrapperStyle = ctx.columnWrapperStyle; +- const size = useValue$("totalSize"); ++ const size = useContentSize(); + const minSize = useCommittedMinSize(); + const [readyToRender, numColumns, otherAxisSize = 0] = useArr$(["readyToRender", "numColumns", "otherAxisSize"]); + const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); +@@ -6492,6 +6540,14 @@ var AlignItemsAtEndSpacer = typedMemo(function AlignItemsAtEndSpacer2({ horizont + null + ); + }); ++// Paseo patch: the header and footer reveal with the rows. Upstream hides only the containers until ++// readyToRender, so a cold open paints the header and footer at their estimated place a frame or more ++// before the rows appear around them. ++var RevealedLayoutView = function RevealedLayoutView2({ freshDataTransitionEpoch, style, ...rest }) { ++ const [readyToRender] = useArr$(["readyToRender"]); ++ const isVisible = useFreshDataTransitionVisibility(!!readyToRender, freshDataTransitionEpoch); ++ return /* @__PURE__ */ React2.createElement(LayoutView, { ...rest, pointerEvents: isVisible ? "auto" : "none", style: isVisible ? style : [style, { opacity: 0 }] }); ++}; + var ListComponent = typedMemo(function ListComponent2({ + canRender, + freshDataTransitionEpoch, +@@ -6630,7 +6686,7 @@ var ListComponent = typedMemo(function ListComponent2({ + style: autoOtherAxisStyle ? [autoOtherAxisStyle, style] : style + }, + /* @__PURE__ */ React2.createElement(ScrollAdjust, null), +- ListHeaderComponent && /* @__PURE__ */ React2.createElement(LayoutView, { onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), ++ ListHeaderComponent && /* @__PURE__ */ React2.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutHeader, style: ListHeaderComponentStyle }, getComponent(ListHeaderComponent)), + ListEmptyComponent && getComponent(ListEmptyComponent), + shouldRenderAlignItemsAtEndSpacer && /* @__PURE__ */ React2.createElement(AlignItemsAtEndSpacer, { horizontal }), + canRender && !ListEmptyComponent && /* @__PURE__ */ React2.createElement( +@@ -6644,7 +6700,7 @@ var ListComponent = typedMemo(function ListComponent2({ + stickyHeaderConfig + } + ), +- ListFooterComponent && /* @__PURE__ */ React2.createElement(LayoutView, { onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), ++ ListFooterComponent && /* @__PURE__ */ React2.createElement(RevealedLayoutView, { freshDataTransitionEpoch, onLayoutChange: onLayoutFooterInternal, style: ListFooterComponentStyle }, getComponent(ListFooterComponent)), + IS_DEV && ENABLE_DEVMODE + ); + }); +@@ -7865,7 +7921,10 @@ function setPaddingTop(ctx, { stylePaddingTop }) { + const state = ctx.state; + if (stylePaddingTop !== void 0) { + const prevStylePaddingTop = peek$(ctx, "stylePaddingTop") || 0; +- if (stylePaddingTop < prevStylePaddingTop) { ++ // Paseo patch: only iOS compensates a shrinking top padding with a scroll adjust, which this ++ // keeps the native clamp from doubling. Elsewhere the held height paints as blank space at the ++ // end until the timer fires, and a scroll view that keeps its end scrolls into it. ++ if (stylePaddingTop < prevStylePaddingTop && Platform.OS === "ios") { + let prevTotalSize = peek$(ctx, "totalSize") || 0; + set$(ctx, "totalSize", prevTotalSize + prevStylePaddingTop); + state.timeoutSetPaddingTop = setTimeout(() => { +@@ -7968,6 +8027,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + estimatedListSize, + extraData, + getFixedItemSize, ++ getEstimatedItemSize, + getItemType, + horizontal, + rtl, +@@ -8261,6 +8321,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded + dataVersion, + drawDistance, + estimatedItemSize, ++ getEstimatedItemSize: useWrapIfItem(getEstimatedItemSize), + getFixedItemSize: useWrapIfItem(getFixedItemSize), + getItemType: useWrapIfItem(getItemType), + hasExternalScroll, diff --git a/patches/react-native-gesture-handler+2.32.0.patch b/patches/react-native-gesture-handler+2.32.0.patch index 7cdbc35d4a..b6e3a810f1 100644 --- a/patches/react-native-gesture-handler+2.32.0.patch +++ b/patches/react-native-gesture-handler+2.32.0.patch @@ -194,10 +194,37 @@ index 96c5227..cbf1a88 100644 // diff --git a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js -index 76eb808..7c40526 100644 +index 76eb808..75a9d75 100644 --- a/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js +++ b/node_modules/react-native-gesture-handler/lib/commonjs/web/tools/PointerEventManager.js -@@ -58,7 +58,14 @@ class PointerEventManager extends _EventManager.default { +@@ -35,9 +35,6 @@ class PointerEventManager extends _EventManager.default { + } + const adaptedEvent = this.mapEvent(event, _interfaces.EventTypes.DOWN); + const target = event.target; +- if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { +- target.setPointerCapture(adaptedEvent.pointerId); +- } + this.markAsInBounds(adaptedEvent.pointerId); + this.trackedPointers.add(adaptedEvent.pointerId); + if (++this.activePointersCounter > 1) { +@@ -46,6 +43,16 @@ class PointerEventManager extends _EventManager.default { + } else { + this.onPointerDown(adaptedEvent); + } ++ ++ // Capture only a pointer the handler still tracks after seeing it. A handler that fails ++ // the pointer on down (a touch-only pan rejecting the mouse) resets, and a capture it kept ++ // would confine the browser's text selection to the pressed element. ++ if ( ++ this.trackedPointers.has(adaptedEvent.pointerId) && ++ !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) ++ ) { ++ target.setPointerCapture(adaptedEvent.pointerId); ++ } + }; + pointerUpCallback = event => { + // When we call reset on gesture handlers, it also resets their event managers +@@ -58,7 +65,14 @@ class PointerEventManager extends _EventManager.default { const adaptedEvent = this.mapEvent(event, _interfaces.EventTypes.UP); const target = event.target; if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { @@ -213,6 +240,15 @@ index 76eb808..7c40526 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); this.trackedPointers.delete(adaptedEvent.pointerId); +@@ -85,7 +99,7 @@ class PointerEventManager extends _EventManager.default { + // incorporating it here seems stupid), so we just call it again here, every time + // pointer moves until it succeeds. + // God, I do love web development. +- if (!target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { ++ if (this.trackedPointers.has(event.pointerId) && !target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { + target.setPointerCapture(event.pointerId); + } + const inBounds = (0, _utils.isPointerInBounds)(this.view, { diff --git a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js b/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js index 0791c66..2f41c77 100644 --- a/node_modules/react-native-gesture-handler/lib/module/web/handlers/GestureHandler.js @@ -229,10 +265,37 @@ index 0791c66..2f41c77 100644 // diff --git a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js -index 6ce3b8f..8fe28bc 100644 +index 6ce3b8f..65cc6c8 100644 --- a/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js +++ b/node_modules/react-native-gesture-handler/lib/module/web/tools/PointerEventManager.js -@@ -53,7 +53,14 @@ export default class PointerEventManager extends EventManager { +@@ -30,9 +30,6 @@ export default class PointerEventManager extends EventManager { + } + const adaptedEvent = this.mapEvent(event, EventTypes.DOWN); + const target = event.target; +- if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { +- target.setPointerCapture(adaptedEvent.pointerId); +- } + this.markAsInBounds(adaptedEvent.pointerId); + this.trackedPointers.add(adaptedEvent.pointerId); + if (++this.activePointersCounter > 1) { +@@ -41,6 +38,16 @@ export default class PointerEventManager extends EventManager { + } else { + this.onPointerDown(adaptedEvent); + } ++ ++ // Capture only a pointer the handler still tracks after seeing it. A handler that fails ++ // the pointer on down (a touch-only pan rejecting the mouse) resets, and a capture it kept ++ // would confine the browser's text selection to the pressed element. ++ if ( ++ this.trackedPointers.has(adaptedEvent.pointerId) && ++ !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) ++ ) { ++ target.setPointerCapture(adaptedEvent.pointerId); ++ } + }; + pointerUpCallback = event => { + // When we call reset on gesture handlers, it also resets their event managers +@@ -53,7 +60,14 @@ export default class PointerEventManager extends EventManager { const adaptedEvent = this.mapEvent(event, EventTypes.UP); const target = event.target; if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { @@ -248,6 +311,15 @@ index 6ce3b8f..8fe28bc 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); this.trackedPointers.delete(adaptedEvent.pointerId); +@@ -80,7 +94,7 @@ export default class PointerEventManager extends EventManager { + // incorporating it here seems stupid), so we just call it again here, every time + // pointer moves until it succeeds. + // God, I do love web development. +- if (!target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { ++ if (this.trackedPointers.has(event.pointerId) && !target?.hasPointerCapture(event.pointerId) && !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { + target.setPointerCapture(event.pointerId); + } + const inBounds = isPointerInBounds(this.view, { diff --git a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index ddb79a3..4e1274c 100644 --- a/node_modules/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -264,10 +336,38 @@ index ddb79a3..4e1274c 100644 // diff --git a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts -index 18d3627..769b0fe 100644 +index 18d3627..106b97c 100644 --- a/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts +++ b/node_modules/react-native-gesture-handler/src/web/tools/PointerEventManager.ts -@@ -67,7 +67,14 @@ export default class PointerEventManager extends EventManager { +@@ -39,10 +39,6 @@ export default class PointerEventManager extends EventManager { + const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.DOWN); + const target = event.target as HTMLElement; + +- if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { +- target.setPointerCapture(adaptedEvent.pointerId); +- } +- + this.markAsInBounds(adaptedEvent.pointerId); + this.trackedPointers.add(adaptedEvent.pointerId); + +@@ -52,6 +48,16 @@ export default class PointerEventManager extends EventManager { + } else { + this.onPointerDown(adaptedEvent); + } ++ ++ // Capture only a pointer the handler still tracks after seeing it. A handler that fails ++ // the pointer on down (a touch-only pan rejecting the mouse) resets, and a capture it kept ++ // would confine the browser's text selection to the pressed element. ++ if ( ++ this.trackedPointers.has(adaptedEvent.pointerId) && ++ !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) ++ ) { ++ target.setPointerCapture(adaptedEvent.pointerId); ++ } + }; + + private pointerUpCallback = (event: PointerEvent) => { +@@ -67,7 +73,14 @@ export default class PointerEventManager extends EventManager { const target = event.target as HTMLElement; if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { @@ -283,3 +383,11 @@ index 18d3627..769b0fe 100644 } this.markAsOutOfBounds(adaptedEvent.pointerId); +@@ -98,6 +111,7 @@ export default class PointerEventManager extends EventManager { + // pointer moves until it succeeds. + // God, I do love web development. + if ( ++ this.trackedPointers.has(event.pointerId) && + !target?.hasPointerCapture(event.pointerId) && + !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) + ) { diff --git a/plugins/package.json b/plugins/package.json index 3e97f0215b..1e56036650 100644 --- a/plugins/package.json +++ b/plugins/package.json @@ -1,6 +1,6 @@ { "name": "@getpaseo/builtin-plugins", - "version": "0.15.0", + "version": "0.15.2", "private": true, "type": "module", "scripts": { diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 3dbbeddcaf..e6b9195a94 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -43,10 +43,18 @@ const patchedPackages = [ nodeModulesPath: "node_modules/react-native-reanimated", patchPrefix: "react-native-reanimated+", }, + // The native chat timeline. The patch's header lists its hunks and the upstream issue. + { + nodeModulesPath: "node_modules/@legendapp/list", + patchPrefix: "@legendapp+list+", + }, { nodeModulesPath: "node_modules/react-native-draggable-flatlist", patchPrefix: "react-native-draggable-flatlist+", }, + // Web pointer capture: capture only pointers the handler still tracks after pointer-down, so a + // touch-only pan that fails the mouse leaves browser text selection alone. Keep until upstream + // stops capturing pointers a handler has rejected. { nodeModulesPath: "node_modules/react-native-gesture-handler", patchPrefix: "react-native-gesture-handler+",