Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] ddfed6afe9 test(server): reproduce queued steer loss at turn completion
Keep controlled completion timing cases and the passing idle-dispatch control for issue #8679. These diagnostic tests intentionally expose dropped delivery; retry semantics remain a maintainer decision because unavailable steering does not establish whether the provider accepted the message.
2026-11-04 10:21:10 +00:00
Mohamed BoudraandElsa Granger 4991efddf8 feat(app): follow iPhone Duo vertical bars and fold divisions (#8741)
* feat(app): follow system vertical bars and fold divisions

Keep custom navigation controls outside system content areas and use active division regions for temporary panel allocation. Normal sidebar preferences resume when the division disappears.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): show window actions in the active portal scope

The bottom-sheet provider creates its own portal context. Register the window action host within that context so symbols reach the root host.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): hide workspace bar actions behind compact panels

Compact panels keep the workspace route focused while covering its content. Hide its portal actions until the workspace panel is active again.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): continue the sidebar surface across divisions

Keep the fold free of controls while painting it as part of the sidebar, with the shared divider at the workspace edge.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): omit tab count beside the full tab rail

The full tab rail already exposes navigation. Use the same compact/full header selection for the vertical action list.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): preserve folded navigation and bar action lifetime

Use resolved sidebar visibility for desktop activity, and keep bar controls mounted while compact panels cover them so pending menu work survives. Carry circular button chrome inside the portal and exercise the rendered boundary in Chromium.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* fix(app): own script actions across header placement changes

Keep one script action instance on the workspace screen so pending restarts survive removal of either menu. Pass activity through the bar portal so covered menus close their overlays without taking workflow state with them.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

* test(app): exercise script ownership through the workspace

Replace synthetic ownership fixtures with a real workspace, script menu and isolated terminal worker. Hold terminal exit until the compact header is removed so moving pending state back into the menu fails the regression. Exercise portal activity with production menu and header controls.

Co-authored-by: Elsa Granger <zeyugao@outlook.com>

---------

Co-authored-by: Elsa Granger <zeyugao@outlook.com>
2026-11-04 11:05:45 +01:00
Mohamed Boudra fcbfb4f0d3 Size controls for touch on iPad and other touch screens (#8748)
* feat(app): size controls for touch on any touch device, not just compact layouts

One gate picks touch or pointer sizing: touch when the layout is compact or
the device has a touch screen. The touch fact is read once, so attaching a
mouse or keyboard to a tablet never shrinks its controls. Stylesheets take
`bySizing({ touch, pointer })`, which resolves to a breakpoint value, so the
existing `{ xs, md }` consumers follow the gate without call-site changes.

Touch values follow the iOS HIG 44pt hit region: controls, icon buttons,
rows, bars and menu rows are 44 on touch; `sm` controls are 32. Glyphs move
to a touch ladder (14/18/20/24). Pointer sizes are unchanged.

Merges duplicated header control heights, density's private icon ladder and
the compact-only icon button sizes into control-geometry.

* feat(app): size the composer for touch through control geometry

The composer toolbar, attach / dictation / realtime / send buttons, context
meter and model triggers take TOOLBAR_CONTROL_SIZES (28 pointer, 44 touch)
instead of private 28 constants. The density budget, button glyphs and the
fullscreen glyph rail offset resolve the same gate, replacing the isWeb /
isNative glyph branches. Track rows join the menu row height.

Desktop geometry is unchanged. On touch the toolbar steps down density
earlier: iPad portrait goes from condensed to tight.

* feat(app): fold composer controls before the model label truncates

The composer toolbar walks one width budget: full, condensed, tight,
compact, then icon-only. Tight keeps the model at its family label;
below that, thinking, mode and features fold into the model control's
sheet (the phone toolbar), and only then does the model drop its label.
A touch iPad in portrait with an attachment now reads
"GPT-6-Astra Medium" instead of "GP…".

The context meter glyph and combobox rows and icons take the density
ladder. Desktop is pixel-identical in the content area.

* refactor(app): keep the sizing gate free of the theme

constants/layout sized its bars through control-geometry, which loads the
theme and runs Platform.select at import. The gate (resolveControlSizing,
bySizing, TOUCH_TARGET) moves to control-sizing.ts with no theme import;
control-geometry re-exports it, so its callers are unchanged.

* test(app): expect touch sizing in the compact Changes and plugin sidebar specs

The compact Changes options button is a 44pt icon button with a 20pt glyph,
and its framed actions trigger is 32 tall. In the compact sidebar the nav
block is capped at half the height, so 44pt rows put the plugin header row
below the cap; the spec scrolls it into view as a user would.

* fix(app): close composer menus when the controls fold or unfold

Folding swaps the toolbar controls for the model sheet. A menu opened on the
separate controls stayed marked open across the fold, so widening the
composer again reopened Thinking without a click.
2026-11-04 11:04:52 +01:00
Monarch MaisuriyaandClaude Opus 5.5 6221b0c8b7 Fork from earlier replies when a plugin card was updated later (#8746)
The fork checkpoint check refused a fork when any projected row spanned the
selected reply. A plugin card re-appended with the same id keeps its first
position and stretches to its latest update, so it spanned every reply in
between, although the fork never copies plugin cards. The check now looks only
at the row kinds the fork copies, taken from the same constant as the copy.

Closes #8745

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:02:25 +00:00
paseo-ai[bot] bfbd4d5175 fix: update lockfile signatures and Nix hash [skip ci] 2026-11-04 08:04:28 +00:00
Mohamed Boudra 52b84b7835 fix(ci): avoid incomplete pkijs signature attestations (#8749)
Pin pkijs to 3.4.2: SEA serves only provenance for 3.4.3. Hoist electron-builder so root overrides apply to its dependency tree.
2026-11-04 14:48:36 +07:00
Mohamed Boudra d68271565a Read agent history after its working directory is removed (#8737)
* fix(server): read agent history after working directory removal

* fix(server): preserve interactive residency when reading agent history

* fix(server): serialize history session upgrades

* fix(server): upgrade history sessions before changing prompt mode

* fix(server): keep archived history out of interactive residency

* fix(server): admit configuration changes through interactive residency

* fix(server): establish interactive residency before rewinding

* fix(server): preserve immediate rewind locking for interactive sessions
2026-11-04 14:10:27 +07:00
paseo-bot[bot]and3ae3ae ce7b14a143 Keep context details open while chat streams (#8742)
* fix(app): keep hover cards open while unrelated content scrolls

Hover cards closed on any scroll outside their content. A streaming
chat scrolls on every new line, so the context window details closed
while an agent was responding. Close only when the scrolled element
contains the trigger, which is the case that moves the card's anchor.

* test(app): confirm the turn is still streaming once the details open

* test(app): cover context clicks and trigger scrolling

Exercise the reported click path without replacing usage transport, and
preserve dismissal when a sidebar scroll moves its trigger.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>

---------

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 07:05:49 +00:00
paseo-bot[bot] c7e137d4b4 test(server): settle missing-folder observation before cleanup (#8743)
Wait for metadata observation setup before deleting and restoring the fixture, and flush its diagnostic trace sink after service disposal so queued appends cannot recreate files during removal.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 06:51:15 +00:00
Mohamed Boudra d37262be56 Keep workspace updates independent of archived history (#8735)
* perf(server): scope workspace updates to indexed records

* fix(server): preserve parent context in scoped workspace queries

* test(server): update remaining scoped store adapters

* fix(server): preserve archived exact paths in archive requests

* fix(server): prefer active exact archive targets
2026-11-04 13:25:24 +07:00
Mohamed Boudra 0952ddbd4b fix(server): recover workspace Git watchers after setup timeouts
* fix(server): recover git watchers after setup timeouts

Setup failures entered degraded polling without scheduling the existing recovery ladder. Retry both watcher types so successful subscriptions stop fallback reads.

* fix(server): isolate callbacks from expired git watchers

An expired subscription can report an error after recovery. Ignore abandoned callbacks and callbacks that no longer own the active subscription so they cannot tear down a replacement.

* fix(server): refresh metadata when git observation recovers

* fix(server): settle watcher recovery during workspace teardown

* fix(server): reconcile exclusions when metadata watching recovers
2026-11-04 13:10:21 +07:00
67628b48fe Keep workspaces usable when mobile navigation overflows (#8727)
* fix(app): cap the compact sidebar nav block so plugins cant crowd out workspaces

The compact sidebar's top nav group (builtin rows plus plugin header
items) was a fixed block with no height bound. On short screens - a
foldable's cover display, for instance - enough plugin items pushed the
scrollable workspace list to zero height, leaving it unusable.

Wrap the nav rows in a bounded ScrollView capped at half the sidebar
height. Rows scroll inside the cap when they overflow; the separator
moves to the scroll frame so it stays fixed at the region's bottom. On
native the scroll view is the gesture-handler variant with the close
pan as a simultaneous handler, matching the workspace list's contract.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(app): keep the mobile nav separator theme-safe and disable iOS bounce

Review follow-ups: the border lived on the gesture-handler ScrollView,
which Unistyles never rewrites, so move the divider to a SidebarSeparator
sibling. On iOS the block rubber-banded under a fixed divider even when
every row fit, so disable alwaysBounceVertical. Inline
simultaneousHandlers now that GHScrollView types it, and tag the region
for Maestro.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(app): keep workspaces reachable with overflowing sidebar navigation

Reproduce the short compact sidebar failure with the existing ten-row plugin fixture. Check that navigation scrolls independently and both navigation actions and workspaces remain usable.

Co-authored-by: hugo <jaredwangcoode@gmail.com>

---------

Co-authored-by: hugo <jaredwangcoode@gmail.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 04:08:50 +00:00
paseo-bot[bot] eba5bdf810 test(server): drain agent snapshots before title fixture cleanup (#8716)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 03:27:40 +00:00
paseo-bot[bot] 66676d49e6 fix(pi): require Reload after the native process exits (#8718)
Reject new turns before accepting work into a known dead Pi runtime. Keep process liveness separate from explicit session cleanup so Reload can still release resources and resume the conversation.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 01:56:00 +00:00
paseo-bot[bot] 064243288b Recognize persisted parents when creating child sessions (#8714)
* fix(server): recognize persisted parents during child creation

Creation only needs the caller's identity and workspace. Read the existing stored record when the parent is unloaded after restart, rather than requiring its provider session to be opened first.

* test(server): run persisted caller regressions in integration CI

* test(server): retry fixture removal while Windows releases handles

* fix(server): keep archived parents ineligible for child creation

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-04 01:12:53 +00:00
Mohamed Boudra a880c9619d feat: background agents for hidden helpers (#8683)
* feat(server): add background agents

An agent can be created background in any workspace. It stays a durable,
addressable agent, but default discovery, workspace status and attention
leave it out. Effective background is agent.background ||
workspace.background.

- Protocol: `create_agent_request.background` now marks the agent itself
  and stays the workspace intent for a workspace the request creates.
  Snapshots carry `background`. The daemon advertises
  `features.backgroundAgents`. Clients advertise the `background_agents`
  capability.
- Daemon: the flag persists in the agent record, so restart and resume
  keep it. AgentDirectory owns visibility. Inclusive reads and
  subscriptions from sources without `background_agents` see only agents
  in background workspaces, because older apps would otherwise open a tab
  for each one.
- Inheritance: omitted on agent-scoped creation, children and new
  workspaces take the caller agent's background.
- MCP: create_agent accepts `background`.
- SDK: placing a background agent in an existing workspace, or under a
  caller, fails fast with "Update the host to use background agents." on
  older hosts.
- CLI: `ls -a` and `ls --background` add a BACKGROUND column.

* feat(app): hide background agents from tabs, track and status

* docs(plugins): point hidden helpers at background agents

* fix: keep background agents hidden across cache reload and connected hosts

* fix: keep remote creation working on older hosts and inherit foreign parents' background
2026-11-04 07:19:21 +07:00
Mohamed Boudra 6740d41fab Open plugin settings from a cog, with a tab per settings screen (#8675)
* feat(app): open plugin settings from a cog with a tab per screen

The plugin row shows a settings button next to its switch when the plugin
registers settings screens. The actions menu keeps Logs, Reload and Remove.
The settings page shows one tab per registered screen; a plugin with one
screen shows no tabs. Tabs swap screenId in place, so existing
plugins/[pluginId]/[screenId] links and openSettings keep working.

* fix(app): render settings tab icons inside the plugin's runtime

Component icons now mount under the installation provider, so plugin SDK
hooks such as useTranslation work. Each icon has its own error boundary;
an icon that throws leaves its tab with the label alone.

* feat(app): name only the plugin in its settings page header

The header shows the plugin's manifest name, or its id when it has none,
for single- and multi-screen plugins alike. The selected tab names the
screen. The Plugins page and the header share one plugin list query.

* feat(app): show plugin display names and a Settings tooltip on the cog

Plugin rows on Settings → Plugins show the manifest name, falling back to
the id; when a name is set, the id stays visible beneath it. The row's
controls, logs sheet, confirmations and feedback use the same name.
The cog is a ToolbarButton labeled Settings, with a matching tooltip.
2026-11-04 07:19:13 +07:00
Mohamed Boudra ca6b54fc2e feat(app): mark composer pills that open a menu and keep the pill track's space (#8673)
Plugin composer pills with a menu or popover now show an upward chevron at
every width, so a pill that opens a surface reads differently from a one-tap
action pill. Built-in Tasks and Subagents pills are unchanged.

The transcript's bottom inset used to be reserved only while some pill
existed. Plugins that add pills when a turn ends and remove them when the next
starts moved the transcript every turn. The inset is now reserved whenever the
agent has an active composer.
2026-11-04 07:19:06 +07:00
paseo-bot[bot] 4cd23a8f17 test(server): wait for worktree observation without racing filesystem setup (#8700)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 21:30:59 +00:00
3084f32852 Show effort controls for Claude settings models (#8699)
* fix(claude): show thinking options for models from settings.json

Models appended from Claude's settings.json were pushed to the catalog
without thinkingOptions, so the composer had no effort selector for them.
Resolve them like profile and additionalModels entries: manifest options for
built-in IDs, the standard Claude effort levels otherwise.

* docs(claude): clarify effort selection for settings models

Describe explicit effort selection without promising how every client initializes an unsaved value.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>
2026-11-03 20:59:32 +00:00
paseo-bot[bot] ce8196f6c5 fix(opencode): keep reopened running conversations interruptible (#8698)
An execution adopted during session initialization emitted turn_started before the manager could subscribe. Deliver the current turn to a new subscriber so the manager owns the run and routes Stop to OpenCode.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:54:49 +00:00
paseo-bot[bot]andSeth Jeon 40b95f70f4 Fix Android chat mouse-wheel direction (#8693)
* fix(android): correct inverted chat wheel direction

* test(android): run wheel regressions with current React Native

Provide the native-only test context with React Native’s event dispatcher for an absent JavaScript instance, retaining real scroll handling.

Co-authored-by: Seth Jeon <paseo@sethjeon.com>

---------

Co-authored-by: Seth Jeon <paseo@sethjeon.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 19:25:40 +00:00
202 changed files with 7038 additions and 2227 deletions
+30 -11
View File
@@ -183,23 +183,42 @@ Some providers can create their own child sessions inside one provider runtime.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Background workspaces
## Background visibility
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
change it. New workspaces default to false, inherit the caller workspace's value when an agent
creates one without an override, and accept explicit true or false.
An agent is effectively background when it or its workspace is background:
`agent.background || workspace.background`. Background controls discovery only. Background agents
and the agents in background workspaces are ordinary durable agents: provider history, plugin
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
normal behavior. Both flags are fixed at creation.
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
including History requests, exclude background work; `filter.includeBackground` includes it.
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
works.
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
background** is on.
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
caller's own `background`. A new workspace takes the caller's effective background. A request
that creates its workspace applies `background` to both the agent and the workspace. Explicit
true or false always wins.
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
Default workspace and agent lists, including History requests, exclude background work;
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
replicates inclusively and filters at each surface.
Sources without the `background_agents` hello capability get workspace-only visibility on
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
Sequenced directories keep complete canonical collections. A filtered read must never replace
those collections with its subset. Inclusive and default observations have distinct cursor
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
They gate a background agent placed in an existing workspace or under a caller on
`features.backgroundAgents`.
### Private daemon helpers
+5 -1
View File
@@ -174,7 +174,7 @@ 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:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection
## Inverted timeline input
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
@@ -183,6 +183,10 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
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.
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
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
+2
View File
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
+5 -3
View File
@@ -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. `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. `<Switch>` 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 `<SegmentedControl>` (`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. `<Switch>` 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 `<SegmentedControl>` (`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 `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
@@ -150,7 +152,7 @@ One row off the rail makes the whole card look unconsidered.
Compact-first. The small case is designed; the large case adds chrome around it.
Use `md: 600` for both the compact/wide layout boundary and responsive density, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint. Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
Use `md: 600` for the compact/wide layout boundary, in logical points on native and CSS pixels on web. Do not add a separate density breakpoint; touch sizing comes from the control sizing gate in [Buttons](#4-buttons). Measured content constraints remain independent: composer controls use `COMPACT_CONTENT_WIDTH` (500), so a narrow pane can keep compact controls inside a wide window.
The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) share the canonical list+detail navigation pattern:
+2 -1
View File
@@ -593,7 +593,8 @@ rerun passes only by landing elsewhere.
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
replace that version in the lockfile and report the URL to npm so they purge it.
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
## ACP provider catalog versions
+2 -1
View File
@@ -96,7 +96,8 @@ hidden workspace can overwrite the remembered workspace before Settings or
History returns.
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
Back returns to that host's Plugins page.
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
so switching tabs adds no stack entries.
Settings detail routes are separate siblings on purpose. Keep
`settings/[section]`, the host routes, the projects index, and project detail as
+1
View File
@@ -28,6 +28,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
+4 -3
View File
@@ -95,9 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
The daemon validates the epoch and locates the projected item at the selected position. A fork
includes projected items through that checkpoint. If an item spans the checkpoint and changed
afterward, the daemon refuses that fork with an actionable error: discarded historical payloads
cannot be reconstructed from sequence metadata. Forking the current context remains available.
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
updated after the checkpoint does not block it. Forking the current context remains available.
## Resume behavior
+1 -1
View File
@@ -1 +1 @@
sha256-VdM4lXdgNEXPpTfvHSFvF9nm302rK/ZbVkZUp9tyOfg=
sha256-p97CaALgkY+x9XVvS4/ILqmbwYSX0fSbVUgQl35BjiU=
+707 -615
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -131,6 +131,7 @@
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "44.2.0",
"electron-builder": "26.18.0",
"expo-module-scripts": "^5.0.8",
"get-port-cli": "^3.0.0",
"js-yaml": "^4.1.1",
@@ -151,6 +152,7 @@
"expo": "~57.0.30",
"expo-router": "~57.0.28",
"lightningcss": "1.30.1",
"pkijs": "3.4.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
@@ -0,0 +1,65 @@
import { test } from "../support/fixtures";
import {
expectWorkspaceShownIdle,
finishAndArchiveAgent,
startBackgroundAgent,
} from "../support/helpers/background-agents";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentsTrack,
seedParentWithSubagent,
seedSubagent,
} from "../support/helpers/subagents";
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
test.describe("Background agents", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({
repoPrefix: "background-agents-",
title: "Classifier workspace",
});
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("a background agent runs in an open workspace without announcing itself", async ({
page,
}) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Feature agent",
childTitle: "Visible helper",
});
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
const classifier = await test.step("a plugin starts a background agent", () =>
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
// A visible child created after the classifier reaches the app on the same stream, so once
// its row shows, the app has processed the classifier too.
const marker = await seedSubagent(workspace, {
parentId: agents.parent.id,
title: "Ordering marker",
});
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
await expectSubagentRowVisible(page, marker.id);
await expectSubagentRowGone(page, classifier.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
await expectWorkspaceShownIdle(page, "Classifier workspace");
});
await test.step("the plugin reads its finish and archives it", async () => {
await finishAndArchiveAgent(workspace, classifier.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
await expectSubagentRowGone(page, classifier.id);
});
});
});
@@ -928,11 +928,11 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
throw new Error("Compact Changes toolbar geometry could not be measured");
}
expect(actionsBox.width).toBe(48);
expect(actionsBox.height).toBe(28);
expect(optionsBox.width).toBe(32);
expect(optionsBox.height).toBe(32);
expect(glyphBox.width).toBe(18);
expect(glyphBox.height).toBe(18);
expect(actionsBox.height).toBe(32);
expect(optionsBox.width).toBe(44);
expect(optionsBox.height).toBe(44);
expect(glyphBox.width).toBe(20);
expect(glyphBox.height).toBe(20);
await expect(actions).not.toContainText("Commit");
await expect(actions.locator("svg")).toHaveCount(2);
@@ -1,5 +1,6 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { pauseWorkspaceTerminalWorker } from "../support/helpers/workspace-setup";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
@@ -190,3 +191,49 @@ test("compact header menu runs, shows, and stops workspace scripts", async ({ pa
await workspace.cleanup();
}
});
async function restartScriptAcrossHeaderRemoval(page: Page, terminalId: string) {
await openRunningScriptPage(page, "lint");
const resumeWorker = await pauseWorkspaceTerminalWorker(terminalId);
try {
await menuRow(page, "Restart").click();
await page.setViewportSize({ width: 1280, height: 844 });
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveCount(0);
await expect(page.getByTestId("workspace-scripts-button")).toBeVisible();
} finally {
resumeWorker();
}
}
async function expectScriptTerminalReplaced(
workspace: Awaited<ReturnType<typeof openWorkspaceWithScripts>>,
previousId: string,
) {
await expect
.poll(
async () => {
const after = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
return after.terminals.filter((terminal) => terminal.id !== previousId).length;
},
{ timeout: 15_000 },
)
.toBe(1);
}
test("pending script restart survives removal of the compact header", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const workspace = await openWorkspaceWithScripts(page);
try {
await runScriptFromScriptsPage(page, "lint");
const before = await workspace.client.listTerminals(undefined, undefined, {
workspaceId: workspace.workspaceId,
});
expect(before.terminals).toHaveLength(1);
await restartScriptAcrossHeaderRemoval(page, before.terminals[0]!.id);
await expectScriptTerminalReplaced(workspace, before.terminals[0]!.id);
} finally {
await workspace.cleanup();
}
});
@@ -5,6 +5,7 @@ import {
recordComposerToolbarFrames,
} from "../support/helpers/composer-control-density";
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
const SETTLE_MS = 1_000;
@@ -92,3 +93,34 @@ test.describe("Composer control density across tab switches", () => {
}
});
});
test.describe("Composer controls folding on a touch device", () => {
test.use({ viewport: { width: 1194, height: 834 }, isMobile: true, hasTouch: true });
test.describe.configure({ timeout: 180_000 });
test("an open menu stays closed after the controls fold and unfold", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "composer-density-fold-",
title: "Folding chat",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, session);
const thinking = page.getByTestId("agent-thinking-selector").filter({ visible: true });
const thinkingMenu = page.getByTestId("combobox-desktop-container");
await thinking.click();
await expect(thinkingMenu).toBeVisible();
// Still a wide layout, but too narrow for separate controls: they fold into the model control.
await page.setViewportSize({ width: 640, height: 834 });
await expect(thinking).toHaveCount(0);
await page.setViewportSize({ width: 1194, height: 834 });
await expect(thinking).toBeVisible();
await expect(thinkingMenu).toHaveCount(0);
} finally {
await session.cleanup();
}
});
});
@@ -429,6 +429,32 @@ test("context details support keyboard entry and dismiss when the viewport moves
await expect(details).toHaveCount(0);
});
test("context details stay open while the chat streams", async ({ page }) => {
test.setTimeout(120_000);
await page.setViewportSize(DESKTOP);
const session = await seedMockAgentWorkspace({
repoPrefix: "context-window-streaming-",
title: "Context details while streaming",
model: "ten-second-stream",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await submitMessageWithButton(page, "Stream while the context details are open.");
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
await expect(stop).toBeVisible();
const details = await pressContextWindowMeter(page, /^Context window/);
await expect(stop).toBeVisible();
await qaScreenshot(page, "context-details-while-streaming");
await expect(stop).toHaveCount(0, { timeout: 30_000 });
await expect(details).toBeVisible();
await qaScreenshot(page, "context-details-after-streaming");
} finally {
await session.cleanup();
}
});
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
const usage = await scriptAgentUsage(page);
await page.setViewportSize(DESKTOP);
@@ -10,6 +10,7 @@ test("plugin header and composer buttons share actions, menus, content, and upda
await buttons.verifyObservationLifetime();
await buttons.openWideMenusAndPopovers();
await buttons.runAndUpdateActions();
await buttons.keepTrackSpaceWithoutPills();
await buttons.openCompactSheets();
await buttons.useCompactWorkspaceMenu();
await buttons.hideAndUnloadOpenButtons();
@@ -16,6 +16,14 @@ import {
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
openPluginsPage,
expectActionsMenuWithoutSettings,
expectNoSettingsCogWithoutScreens,
openTabbedSettings,
selectSettingsTab,
openTabbedSettingsLink,
expectSettingsTooltip,
expectNamedPluginRow,
} from "../support/helpers/plugin-settings";
test("display preferences survive a reload", async ({ page }) => {
@@ -49,6 +57,32 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
await returnToPluginsPage(page);
});
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
await openPluginsPage(page);
await test.step("plugin rows show the display name", async () => {
await expectNamedPluginRow(page);
});
await test.step("the cog explains itself", async () => {
await expectSettingsTooltip(page);
});
await test.step("the actions menu keeps only plugin actions", async () => {
await expectActionsMenuWithoutSettings(page);
});
await test.step("tabs switch between the plugin's settings screens", async () => {
await openTabbedSettings(page);
await selectSettingsTab(page, "Advanced");
await selectSettingsTab(page, "General");
await returnToPluginsPage(page);
});
await test.step("a plugin without settings screens shows no cog", async () => {
await expectNoSettingsCogWithoutScreens(page);
});
});
test("a settings screen link opens its tab", async ({ page }) => {
await openTabbedSettingsLink(page, "Advanced");
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
@@ -2,6 +2,7 @@ import path from "node:path";
import type { Locator, Page } from "@playwright/test";
import { expect, test } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { gotoAppShell } from "../support/helpers/app";
import {
BOTS_PLUGIN_ID,
COMPACT,
@@ -176,6 +177,37 @@ test.describe("Plugin sidebar items", () => {
await cleanup();
});
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 600 });
await gotoAppShell(page);
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
await expect(botRow(page, "status")).toBeAttached();
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(workspace).toBeInViewport({ ratio: 1 });
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
const before = await boxOf(workspace);
await visibleTestId(page, "sidebar-global-new-workspace").hover();
await page.mouse.wheel(0, 800);
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
await expect(workspace).toBeInViewport({ ratio: 1 });
expect((await boxOf(workspace)).y).toBe(before.y);
await botRow(page, "status").click();
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
});
await test.step("the workspace still opens and closes the sidebar", async () => {
await workspace.click();
await expect(
page.getByRole("button", { name: "Open menu", exact: true }).first(),
).toBeVisible();
await expect(workspace).not.toBeInViewport();
});
});
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
await page.setViewportSize(WIDE);
await gotoWorkspace(page, workspaceId);
@@ -285,7 +317,10 @@ test.describe("Plugin sidebar items", () => {
const compactSync = footerItem(page, SHOWCASE_PLUGIN_ID, "sync");
await expect(compactSync).toBeInViewport();
await expectFooterRow(page, compactSync);
await expect(headerRow(page, SHOWCASE_PLUGIN_ID, "deploys")).toBeInViewport();
// The compact nav block is capped at half the sidebar and scrolls past it.
const compactDeploys = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
await compactDeploys.scrollIntoViewIfNeeded();
await expect(compactDeploys).toBeInViewport();
await qaScreenshot(page, "phase2-compact-sidebar");
await qaScreenshot(page, "phase5-compact-footer", sidebarFooter(page));
await compactSync.click();
@@ -13,6 +13,7 @@ import {
movePointerAwayFromWorkspaceHoverCard,
openMobileAgentSidebar,
pinWorkspaceFromSidebar,
scrollWorkspaceList,
} from "../support/helpers/sidebar";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
@@ -582,6 +583,12 @@ test.describe("Half-screen desktop layout", () => {
await page.getByTestId("menu-button").click();
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
await movePointerAwayFromWorkspaceHoverCard(page);
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
await expectWorkspaceHoverCardOpen(page);
await scrollWorkspaceList(page);
await expectWorkspaceHoverCardClosed(page);
} finally {
await workspace.cleanup();
}
@@ -6,6 +6,11 @@ env APP_ID=sh.paseo.debug
# Hold past drag arming (180ms), then release.
open "${APP_ID}"
press "label=\"Open menu\""
wait "id=\"sidebar-close\"" 10000
press "id=\"sidebar-close\""
# The close target must clear system controls, including a trailing status column.
wait "label=\"Open menu\"" 5000
press "label=\"Open menu\""
wait "id=\"${WORKSPACE_ROW}\"" 10000
longpress "id=\"${WORKSPACE_ROW}\"" 250
press "id=\"${WORKSPACE_ROW}\""
@@ -0,0 +1,48 @@
import { expect, type Page } from "@playwright/test";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { SeededWorkspace } from "./seed-client";
/**
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
* streaming for about ten seconds. Resolves once the daemon reports it running.
*/
export async function startBackgroundAgent(
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
input: { parentId: string; title: string },
): Promise<{ id: string }> {
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.title,
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: "classify the last turn",
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
background: true,
});
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
return { id: agent.id };
}
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
export async function finishAndArchiveAgent(
workspace: Pick<SeededWorkspace, "client">,
agentId: string,
): Promise<void> {
const finished = await workspace.client.waitForFinish(agentId, 60_000);
expect(finished.status).toBe("idle");
await workspace.client.archiveAgent(agentId);
}
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
const row = page.getByRole("button", {
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
});
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -54,12 +54,15 @@ export async function leaveContextWindowMeter(page: Page): Promise<void> {
await expect(contextWindowDetails(page)).toHaveCount(0);
}
/** Compact screens open the meter's details in a sheet. */
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
/** Clicking the meter opens its details in the active layout. */
export async function pressContextWindowMeter(
page: Page,
name: string | RegExp = METER_NAME,
): Promise<Locator> {
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
const sheet = contextWindowDetails(page);
await expect(sheet).toBeVisible();
return sheet;
const details = contextWindowDetails(page);
await expect(details).toBeVisible();
return details;
}
export async function closeContextWindowSheet(page: Page): Promise<void> {
@@ -4,6 +4,9 @@ import path from "node:path";
import type { Locator, TestInfo } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
import { expectTurnCopyButton } from "./agent-stream";
import { submitMessage } from "./composer";
import { buildAgentRoute } from "./mock-agent";
import { openCommandCenter } from "./command-center";
import {
@@ -88,7 +91,7 @@ export default function contribute(client) {
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
command("finish", "Finish deployment", () => finish());
command("finish-review", "Finish review", () => finishReview());
@@ -102,10 +105,16 @@ export default function contribute(client) {
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
const lifetime = new AbortController();
let pillsHidden = false;
const applyWorkspace = (workspace) => {
if (workspace.id !== workspaceId) return;
const hidePills = workspace.name === "No composer pills";
deploy.update({ visible: workspace.name !== "Compact checks" });
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
if (hidePills === pillsHidden) return;
pillsHidden = hidePills;
review.update({ visible: !hidePills });
usage.update({ visible: !hidePills });
};
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
subscription.subscribe({
@@ -244,6 +253,38 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
await page.emulateMedia({ colorScheme: "light" });
}
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
function chevronOf(page: Page, title: string): Locator {
return page
.getByRole("button", { name: title, exact: true })
.getByTestId("plugin-button-chevron");
}
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
async function expectComposerPillChevrons(page: Page) {
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
await expect(chevronOf(page, "Run review")).toHaveCount(0);
}
async function setComposerPillsShown(
page: Page,
showcase: { setTitle(title: string): Promise<unknown> },
shown: boolean,
) {
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
for (const title of COMPOSER_PILLS) {
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
}
}
async function lastTurnTop(page: Page): Promise<number> {
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
expect(box).not.toBeNull();
return box!.y;
}
async function expectHeaderTextTruncated(page: Page) {
const title = page.getByTestId("workspace-header-title");
const subtitle = page.getByTestId("workspace-header-subtitle");
@@ -276,6 +317,7 @@ export async function withButtonShowcase(
run: (buttons: {
openWideMenusAndPopovers(): Promise<void>;
runAndUpdateActions(): Promise<void>;
keepTrackSpaceWithoutPills(): Promise<void>;
openCompactSheets(): Promise<void>;
useCompactWorkspaceMenu(): Promise<void>;
hideAndUnloadOpenButtons(): Promise<void>;
@@ -348,11 +390,7 @@ export async function withButtonShowcase(
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(1);
await expect(
page
.getByRole("button", { name: "Composer checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expectComposerPillChevrons(page);
await expectSurfaceVisible(page);
await captureSchemes(
page,
@@ -476,10 +514,31 @@ export async function withButtonShowcase(
"Review ready",
);
}),
keepTrackSpaceWithoutPills: () =>
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
await page.setViewportSize(WIDE);
await submitMessage(page, "Stream a turn under the composer pills");
await expectTurnCopyButton(page);
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
for (const viewport of [WIDE, COMPACT]) {
await page.setViewportSize(viewport);
await scrollAgentChatToBottom(page);
const top = await lastTurnTop(page);
await setComposerPillsShown(page, showcase, false);
expect(await lastTurnTop(page)).toBe(top);
await setComposerPillsShown(page, showcase, true);
expect(await lastTurnTop(page)).toBe(top);
}
await page.setViewportSize(WIDE);
}),
openCompactSheets: () =>
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
).toHaveCount(0);
await expectComposerPillChevrons(page);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
await expect(
page.getByRole("button", { name: "Composer checks", exact: true }),
).toContainText("Checks");
@@ -1,4 +1,7 @@
import { copyPluginExample } from "./plugin-fixture";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
@@ -9,6 +12,54 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
const TABBED_PLUGIN_ID = "settings-tabs";
const TABBED_PLUGIN_NAME = "Tabbed settings";
/** Two settings screens, so the settings page shows a tab per screen. */
const TABBED_SOURCE = `import React from "react";
import { Text, View } from "react-native";
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
function General({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
}
function Advanced({ theme }) {
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
}
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
function TranslatedIcon({ color, size }) {
const { t } = useTranslation(messages);
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
}
function BrokenIcon() {
throw new Error("Settings tab icon exploded");
}
export default function contribute(client) {
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
return () => {};
}
`;
async function writeTabbedPlugin() {
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: TABBED_PLUGIN_ID,
name: TABBED_PLUGIN_NAME,
requirements: pluginRequirements,
}),
);
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
}
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
settingsPlugin: [
async ({ e2eWorker, page }, provide, testInfo) => {
@@ -16,16 +67,20 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const example = await copyPluginExample("settings");
const tabbed = await writeTabbedPlugin();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await client.installDirectoryPlugin(tabbed.directory);
await provide(client);
await page.screenshot({ path: testInfo.outputPath("settings.png") });
} finally {
await client.removePlugin("settings-example");
await client.removePlugin(TABBED_PLUGIN_ID);
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
await example.cleanup();
await tabbed.cleanup();
}
},
{ auto: true },
@@ -44,9 +99,88 @@ export async function openCompactDisplaySettings(page: Page) {
}
async function openPluginScreen(page: Page) {
await openHostSection(page, getServerId(), "plugins");
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await openSettingsFromCog(page);
}
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
function settingsButton(page: Page, pluginId: string) {
return page
.getByTestId(`plugin-row-${pluginId}`)
.filter({ visible: true })
.getByRole("button", { name: "Settings", exact: true });
}
async function openSettingsFromCog(page: Page) {
await settingsButton(page, "settings-example").click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
await expectSettingsHeader(page, "settings-example");
}
export async function openPluginsPage(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openHostSection(page, getServerId(), "plugins");
}
export async function expectActionsMenuWithoutSettings(page: Page) {
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
}
export async function expectNoSettingsCogWithoutScreens(page: Page) {
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
await expect(settingsButton(page, "settings-example")).toBeVisible();
}
export async function expectSettingsTooltip(page: Page) {
await settingsButton(page, "settings-example").hover();
await expect(page.getByRole("tooltip")).toHaveText("Settings");
await page.mouse.move(0, 0);
}
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
export async function expectNamedPluginRow(page: Page) {
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
await expect(
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
).toBeVisible();
}
export async function openTabbedSettings(page: Page) {
await settingsButton(page, TABBED_PLUGIN_ID).click();
await expectSettingsTab(page, "General");
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
}
function settingsTab(page: Page, title: string) {
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
}
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
await settingsTab(page, title).click();
await expectSettingsTab(page, title);
}
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
const other = title === "General" ? "Advanced" : "General";
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
// The header names the plugin by its manifest name; the tab names the screen.
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
await expectAppRoute(
page,
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
}
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
await gotoAppShell(page);
await page.goto(
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
);
await expectSettingsTab(page, title);
}
export async function groupByWorkspace(page: Page) {
await page.getByRole("button", { name: /^Group agents by:/ }).click();
@@ -101,11 +235,7 @@ export async function discardTitle(page: Page) {
}
export async function returnAndReopenSettings(page: Page) {
await goBackInSettings(page);
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
await expect(actions).toBeVisible();
await actions.click();
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
await openSettingsFromCog(page);
}
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
@@ -114,6 +244,10 @@ export async function returnToPluginsPage(page: Page) {
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
async function expectSettingsHeader(page: Page, title: string) {
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
@@ -118,6 +118,7 @@ export interface SeedDaemonClient {
featureValues?: Record<string, unknown>;
initialPrompt?: string;
labels?: Record<string, string>;
background?: boolean;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
@@ -71,6 +71,15 @@ export async function focusWorkspaceRowWithKeyboard(
await expect(row).toBeFocused();
}
export async function scrollWorkspaceList(page: Page): Promise<void> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
const before = await list.evaluate((element) => element.scrollTop);
await list.evaluate((element) =>
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
);
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
@@ -1,3 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { realpathSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -380,3 +382,21 @@ export function observeSetupPanelTraffic(page: Page) {
export function setupCommandOutput(detail: { commands: Array<{ log: string }> }): string {
return detail.commands.map((command) => command.log).join("\n");
}
/** Hold this isolated daemon's terminal worker so script exit cannot race a layout change. */
export async function pauseWorkspaceTerminalWorker(terminalId: string): Promise<() => void> {
const home = process.env.E2E_PASEO_HOME;
if (!home) throw new Error("Missing isolated E2E home");
const directory = path.join(home, "terminal-worker");
for (const name of await readdir(directory)) {
if (!name.endsWith(".json")) continue;
const record = JSON.parse(await readFile(path.join(directory, name), "utf8")) as {
pid: number;
terminals: Array<{ id: string }>;
};
if (!record.terminals.some((terminal) => terminal.id === terminalId)) continue;
process.kill(record.pid, "SIGSTOP");
return () => process.kill(record.pid, "SIGCONT");
}
throw new Error(`No isolated terminal worker owns ${terminalId}`);
}
@@ -4,11 +4,13 @@ import android.graphics.Rect
import android.text.Selection
import android.text.Spannable
import android.view.View
import android.view.InputDevice
import android.view.MotionEvent
import android.os.SystemClock
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
@@ -24,6 +26,40 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScrollSelectionTest {
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
}
}
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
val before = scroll.scrollY
withWheel(1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY < before)
}
val afterUp = scroll.scrollY
withWheel(-1f) { event ->
assertTrue(scroll.dispatchGenericMotionEvent(event))
assertTrue(scroll.scrollY > afterUp)
}
}
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
scroll.setScrollEnabled(false)
val before = scroll.scrollY
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
assertEquals(before, scroll.scrollY)
}
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
@@ -70,7 +106,7 @@ class ScrollSelectionTest {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext)
val reactContext = ScrollTestContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
@@ -98,4 +134,26 @@ class ScrollSelectionTest {
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
val time = SystemClock.uptimeMillis()
val properties = MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
}
val coordinates = MotionEvent.PointerCoords().apply {
x = 150f
y = 150f
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
}
val event = MotionEvent.obtain(
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
)
try {
check(event)
} finally {
event.recycle()
}
}
}
@@ -0,0 +1,19 @@
package sh.paseo.scroll;
import android.content.Context;
import com.facebook.react.bridge.BridgeReactContext;
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.events.EventDispatcherProvider;
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
ScrollTestContext(Context context) {
super(context);
}
@Override
public EventDispatcher getEventDispatcher() {
return BlackHoleEventDispatcher.INSTANCE;
}
}
@@ -2,6 +2,8 @@ package sh.paseo.scroll
import android.graphics.Matrix
import android.graphics.Rect
import android.view.InputDevice
import android.view.MotionEvent
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
@@ -30,13 +32,47 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
private val transformValues = FloatArray(9)
private fun isVerticallyInverted(): Boolean {
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
// rotation with positive scaleY. Read the resulting transform, not scaleY.
matrix.getValues(transformValues)
return transformValues[Matrix.MSCALE_Y] < 0
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
return super.onGenericMotionEvent(event)
}
if (!scrollEnabled) return false
// Android applies wheel axes directly to scrollY; the inversion transform only
// transforms pointer coordinates. Reverse the vertical axis before delegating so
// platform scroll bounds and scroll events still apply.
val properties = Array(event.pointerCount) { index ->
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
}
val coordinates = Array(event.pointerCount) { index ->
MotionEvent.PointerCoords().also {
event.getPointerCoords(index, it)
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
}
}
val correctedEvent = MotionEvent.obtain(
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
event.deviceId, event.edgeFlags, event.source, event.flags,
)
return try {
super.onGenericMotionEvent(correctedEvent)
} finally {
correctedEvent.recycle()
}
}
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
matrix.getValues(transformValues)
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
@@ -0,0 +1,4 @@
{
"platforms": ["ios"],
"ios": { "modules": ["PaseoWindowLayoutModule"] }
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'PaseoWindowLayout'
s.version = '0.1.0'
s.summary = 'System window layout geometry for Paseo'
s.description = 'Publishes system bar and division geometry'
s.license = 'Apache-2.0'
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '16.4' }
s.swift_version = '5.4'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.swift'
end
@@ -0,0 +1,110 @@
import ExpoModulesCore
import UIKit
public final class PaseoWindowLayoutModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoWindowLayout")
View(WindowLayoutView.self) {
Events("onGeometryChange")
}
}
}
final class WindowLayoutView: ExpoView {
let onGeometryChange = EventDispatcher()
private var updateScheduled = false
private var previousPayload: [String: Any]?
private var divisions: [CGRect] = []
private var barGuide: UILayoutGuide?
private var barEdge = "none"
private var measuredBounds = CGRect.zero
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
isUserInteractionEnabled = false
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
registerForTraitChanges(UITraitCollection.systemTraitsAffectingVerticalBarEdge) {
(view: WindowLayoutView, _) in
view.setNeedsUpdateConstraints()
view.scheduleUpdate()
}
}
#endif
}
override func didMoveToWindow() {
super.didMoveToWindow()
previousPayload = nil
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
setNeedsUpdateConstraints()
scheduleUpdate()
}
override func layoutSubviews() {
super.layoutSubviews()
if bounds != measuredBounds {
measuredBounds = bounds
setNeedsUpdateConstraints()
}
scheduleUpdate()
}
override func updateConstraints() {
#if compiler(>=6.4)
if #available(iOS 27.1, *) {
// UIKit tracks these reads and invalidates constraints when regions change.
divisions = reservedRegions(kind: .division).compactMap { $0.isActive ? $0.frame : nil }
let edge: String
switch traitCollection.verticalBarEdge {
case .leading: edge = "leading"
case .trailing: edge = "trailing"
default: edge = "none"
}
if edge != barEdge {
barEdge = edge
barGuide = edge == "none" ? nil : layoutGuide(
for: .bar(onEdge: edge == "trailing" ? .right : .left, extent: 44)
)
}
}
#endif
super.updateConstraints()
scheduleUpdate()
}
private func scheduleUpdate() {
guard !updateScheduled else { return }
updateScheduled = true
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.updateScheduled = false
self.publish()
}
}
private func publish() {
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
let bar = barGuide?.layoutFrame
var barPayload: Any = NSNull()
if let bar, bar.width > 0, bar.height > 0 { barPayload = rect(bar) }
let payload: [String: Any] = [
"bounds": rect(bounds),
"verticalBarEdge": barEdge,
"verticalBar": barPayload,
"divisions": divisions.map(rect)
]
guard !NSDictionary(dictionary: payload).isEqual(to: previousPayload ?? [:]) else { return }
previousPayload = payload
onGeometryChange(payload)
}
private func rect(_ frame: CGRect) -> [String: CGFloat] {
["x": frame.minX, "y": frame.minY, "width": frame.width, "height": frame.height]
}
}
+48 -20
View File
@@ -1,4 +1,12 @@
import { useHardwareKeyboardPresent } from "@/keyboard";
import {
WindowLayoutProvider,
WindowLayoutActionsHost,
WindowLayoutSurface,
WindowLayoutDivisionGap,
useWindowLayout,
resolveDesktopDivisionLayout,
} from "@/window-layout";
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
@@ -59,6 +67,7 @@ import {
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
} from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
@@ -355,9 +364,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
const cycleTheme = useCallback(() => {
void updateSettings({ theme: getNextThemePreference(settings.theme) });
@@ -418,6 +434,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isCompactLayout,
isMounted: desktopSidebarMounted,
isOpen: isDesktopAgentListOpen,
divisionLayout,
canShare: canDesktopAppSidebarShare({
contentMinimumWidth: appContentMinimumWidth,
requestedSidebarWidth: sidebarWidth,
@@ -441,18 +458,19 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
if (isWeb) {
themedSidebarChrome = <AppearanceStyleBoundary>{sidebarChrome}</AppearanceStyleBoundary>;
}
const workspaceChromeStyle = useMemo(
() => [rowStyle, !isCompactLayout && { paddingLeft: insets.left, paddingRight: insets.right }],
[insets.left, insets.right, isCompactLayout],
);
const workspaceChrome = (
<View style={workspaceChromeStyle}>
<WindowLayoutSurface compact={isCompactLayout} style={rowStyle}>
<WorkspaceLayoutModePublisher />
{!isCompactLayout ? (
<WindowChromeRegion corners={appChromeLayout.sidebarCorners}>
{themedSidebarChrome}
</WindowChromeRegion>
) : null}
<WindowLayoutDivisionGap
layout={divisionLayout}
sidebarVisible={desktopSidebarVisible}
compact={isCompactLayout}
/>
<WorkspaceExplorerOpenGesture enabled={chromeEnabled && isCompactLayout}>
<WindowChromeRegion
corners={isCompactLayout && chromeEnabled ? "both" : appChromeLayout.contentCorners}
@@ -460,7 +478,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
<View style={flexStyle}>{children}</View>
</WindowChromeRegion>
</WorkspaceExplorerOpenGesture>
</View>
</WindowLayoutSurface>
);
// Native panel gesture hosts outlive appearance keys, like native navigators.
@@ -548,8 +566,7 @@ function SidebarChrome({
}) {
const isCompactLayout = useIsCompactFormFactor();
const isMobileActive = useIsMobilePanelActive("agent-list");
const isDesktopOpen = usePanelStore((state) => state.desktop.agentListOpen);
const active = visible && (isCompactLayout ? isMobileActive : isDesktopOpen);
const active = resolveSidebarActivity(visible, isCompactLayout, isMobileActive);
const hardwareKeyboardPresent = useHardwareKeyboardPresent();
return (
<SidebarModelProvider
@@ -875,20 +892,31 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
// auth, settings, ...) must wrap PortalProvider, not be wrapped by it.
// BottomSheetModalProvider is the exception: Gorhom modals consume portal
// context and need one shared provider for sibling sheets to stack.
function RootOverlayProviders({ children }: { children: ReactNode }) {
return (
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>
{children}
<WindowLayoutActionsHost />
</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
);
}
function RootProviders({ children }: { children: ReactNode }) {
return (
<KeyboardActionDispatcherProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<ToastProvider>
<PortalProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
<WindowLayoutProvider>
<WindowChromeProvider>
<KeyboardProvider>
<KeyboardShiftProvider>
<RootOverlayProviders>{children}</RootOverlayProviders>
</KeyboardShiftProvider>
</KeyboardProvider>
</WindowChromeProvider>
</WindowLayoutProvider>
</KeyboardActionDispatcherProvider>
);
}
@@ -13,6 +13,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const EMPTY_COMBOBOX_OPTIONS: ComboboxOption[] = [];
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
@@ -292,7 +293,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: 0,
},
trigger: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -9,6 +9,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useHostReportsUsage } from "@/usage";
import type { Theme } from "@/styles/theme";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { ContextWindowDetails } from "./context-window-details";
import { ContextWindowSheet } from "./context-window-sheet";
import { formatContextPercentage } from "./context-window-meter.utils";
@@ -306,14 +307,14 @@ export function ContextWindowMeter({
const styles = StyleSheet.create((theme) => ({
container: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
containerWithLabel: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -1,13 +1,97 @@
import { describe, expect, it } from "vitest";
import {
resolveDesktopDivisionLayout,
resolveWorkspaceActionPlacement,
resolveWorkspaceBarActions,
type WindowLayout,
} from "@/window-layout";
import {
canDesktopAppSidebarShare,
resolveDesktopAppChromeLayout,
resolveDesktopAppContentMinimum,
resolveDesktopSidebarVisibility,
resolveSidebarActivity,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
describe("desktop sidebar layout", () => {
const geometry: WindowLayout = {
bounds: { x: 0, y: 0, width: 951, height: 669 },
verticalBarEdge: "trailing",
verticalBar: { x: 875, y: 160, width: 44, height: 450 },
divisions: [{ x: 465, y: 0, width: 21, height: 669 }],
};
it("places workspace action symbols in the reported trailing bar", () => {
expect(resolveWorkspaceActionPlacement(geometry)).toBe("vertical-bar");
expect(resolveWorkspaceActionPlacement(null)).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "none" })).toBe(
"header",
);
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBar: null })).toBe("header");
expect(resolveWorkspaceActionPlacement({ ...geometry, verticalBarEdge: "leading" })).toBe(
"header",
);
});
it("shows the tab count only for the compact header selection", () => {
expect(resolveWorkspaceBarActions("compact")).toEqual(["tab-count", "overflow", "explorer"]);
expect(resolveWorkspaceBarActions("full")).toEqual(["overflow", "explorer"]);
});
it("allocates both panels around a live division without clamping to saved sidebar widths", () => {
const input = {
geometry,
windowWidth: 951,
windowHeight: 669,
leftInset: 0,
rightInset: 84,
};
expect(resolveDesktopDivisionLayout(input)).toEqual({
sidebarWidth: 465,
gap: 21,
contentWidth: 381,
});
expect(
resolveDesktopDivisionLayout({ ...input, geometry: { ...geometry, divisions: [] } }),
).toBeNull();
expect(
resolveDesktopDivisionLayout({ ...input, windowWidth: 669, windowHeight: 951 }),
).toBeNull();
expect(resolveDesktopDivisionLayout({ ...input, geometry: null })).toBeNull();
expect(
resolveDesktopDivisionLayout({
...input,
geometry: { ...geometry, divisions: [{ x: 0, y: 325, width: 951, height: 19 }] },
}),
).toBeNull();
});
it("activates a revealed folded sidebar while preserving closed desktop intent", () => {
expect(resolveSidebarActivity(true, false, false)).toBe(true);
expect(resolveSidebarActivity(false, false, false)).toBe(false);
expect(resolveSidebarActivity(true, true, false)).toBe(false);
expect(resolveSidebarActivity(true, true, true)).toBe(true);
});
it("temporarily reveals navigation for a division without changing the saved closed intent", () => {
const visibility = {
chromeEnabled: true,
isCompactLayout: false,
isMounted: true,
isOpen: false,
canShare: false,
};
expect(
resolveDesktopSidebarVisibility({
...visibility,
divisionLayout: { sidebarWidth: 465, gap: 21, contentWidth: 381 },
}),
).toBe(true);
expect(resolveDesktopSidebarVisibility({ ...visibility, divisionLayout: null })).toBe(false);
expect(visibility.isOpen).toBe(false);
});
it("keeps a retained sidebar hidden while app chrome is suppressed", () => {
expect(
resolveDesktopSidebarVisibility({
@@ -1,6 +1,7 @@
import { EXPLORER_WORKSPACE_MIN_WIDTH } from "@/components/explorer-sidebar-layout";
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
import type { DesktopDivisionLayout } from "@/window-layout";
const MIN_DESKTOP_CENTER_WIDTH = 400;
@@ -10,13 +11,13 @@ export function resolveDesktopSidebarVisibility(input: {
isMounted: boolean;
isOpen: boolean;
canShare: boolean;
divisionLayout?: DesktopDivisionLayout | null;
}): boolean {
return (
input.chromeEnabled &&
!input.isCompactLayout &&
input.isMounted &&
input.isOpen &&
input.canShare
(input.divisionLayout != null || (input.isOpen && input.canShare))
);
}
@@ -85,3 +86,11 @@ export function canDesktopAppSidebarShare(input: {
input.contentMinimumWidth
);
}
export function resolveSidebarActivity(
visible: boolean,
compact: boolean,
mobileActive: boolean,
): boolean {
return visible && (!compact || mobileActive);
}
+84 -29
View File
@@ -12,12 +12,13 @@ import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
Pressable,
ScrollView,
StyleSheet as RNStyleSheet,
Text,
useWindowDimensions,
View,
} from "react-native";
import { Gesture } from "react-native-gesture-handler";
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -25,6 +26,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarCloseButton } from "@/components/headers/sidebar-close-button";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { useWindowLayout, resolveDesktopDivisionLayout } from "@/window-layout";
import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
@@ -41,6 +43,7 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { isNative as platformIsNative } from "@/constants/platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useImportSession } from "@/hooks/use-import-session";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
@@ -526,6 +529,7 @@ function MobileSidebar({
insetsBottom,
closeSidebar,
}: MobileSidebarProps) {
const insets = useSafeAreaInsets();
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const { gesture: closeGesture, gestureRef: closeGestureRef } = useCloseAgentListGesture();
@@ -540,10 +544,10 @@ function MobileSidebar({
onPress={closeSidebar}
testID="sidebar-close"
nativeID="sidebar-close"
style={styles.mobileCloseButton}
style={styles.mobileCloseButton(insets.right)}
/>
),
[labels.closeSidebar, closeSidebar],
[labels.closeSidebar, closeSidebar, insets.right],
);
const mobileSidebarInsetStyle = useMemo(
@@ -555,6 +559,14 @@ function MobileSidebar({
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
);
const navRows = (
<SidebarNavRows
style={styles.mobileNavRowsGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
);
return (
<MobilePanelOverlay
panel="agent-list"
@@ -563,11 +575,29 @@ function MobileSidebar({
>
<View style={styles.sidebarContent} pointerEvents="auto">
<WindowChromeSafeArea placement="below" />
<SidebarNavRows
style={styles.sidebarHeaderGroup}
onBeforeNavigate={closeSidebar}
firstRowTrailing={closeButton}
/>
{platformIsNative ? (
<GHScrollView
// Same contract as the workspace list: vertical drags scroll the nav
// block, a clear horizontal intent still activates the close pan.
simultaneousHandlers={closeGestureRef}
style={styles.mobileNavScroll}
showsVerticalScrollIndicator={false}
alwaysBounceVertical={false}
testID="sidebar-nav-scroll"
>
{navRows}
</GHScrollView>
) : (
<ScrollView
style={styles.mobileNavScroll}
showsVerticalScrollIndicator={false}
alwaysBounceVertical={false}
testID="sidebar-nav-scroll"
>
{navRows}
</ScrollView>
)}
<SidebarSeparator />
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
@@ -638,13 +668,22 @@ function DesktopSidebar({
const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const viewportWidth = windowWidth - insets.left - insets.right;
const visibleSidebarWidth = resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
const divisionLayout = resolveDesktopDivisionLayout({
geometry: useWindowLayout(),
windowWidth,
windowHeight,
leftInset: insets.left,
rightInset: insets.right,
});
const visibleSidebarWidth =
divisionLayout?.sidebarWidth ??
resolveDesktopSidebarWidth({
requestedWidth: sidebarWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleSidebarWidth);
const resizeWidth = useSharedValue(visibleSidebarWidth);
@@ -659,6 +698,7 @@ function DesktopSidebar({
const resizeGesture = useMemo(
() =>
Gesture.Pan()
.enabled(divisionLayout === null)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onBegin(() => {
scheduleOnRN(showResizeGrip);
@@ -680,13 +720,14 @@ function DesktopSidebar({
viewportWidth,
});
})
.onEnd(() => {
runOnJS(setSidebarWidth)(resizeWidth.value);
.onEnd((_event, success) => {
if (success) runOnJS(setSidebarWidth)(resizeWidth.value);
})
.onFinalize(() => {
scheduleOnRN(hideResizeGrip);
}),
[
divisionLayout,
hideResizeGrip,
resizeWidth,
setSidebarWidth,
@@ -697,7 +738,7 @@ function DesktopSidebar({
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
width: divisionLayout?.sidebarWidth ?? resizeWidth.value,
}));
const desktopSidebarStyle = useMemo(
@@ -710,10 +751,10 @@ function DesktopSidebar({
);
const desktopSidebarBorderStyle = useMemo(
() => [
styles.desktopSidebarBorder,
styles.desktopSidebarBorder(divisionLayout !== null),
{ flex: 1, paddingTop: insetsTop, paddingBottom: insets.bottom },
],
[insetsTop, insets.bottom],
[insetsTop, insets.bottom, divisionLayout],
);
const sidebarHeaderGroupStyle = useMemo(
() => [styles.sidebarHeaderGroup, ownsTopLeft && styles.sidebarHeaderGroupBelowChrome],
@@ -784,12 +825,14 @@ function DesktopSidebar({
handleOpenHostSettings={handleOpenHostSettings}
/>
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
{divisionLayout === null ? (
<SidebarResizeHandle
edge="right"
gesture={resizeGesture}
pressed={resizePressed}
testID="left-sidebar-resize-handle"
/>
) : null}
</View>
</Animated.View>
);
@@ -839,18 +882,30 @@ const styles = StyleSheet.create((theme) => ({
sidebarHeaderGroupBelowChrome: {
paddingTop: 0,
},
// The nav block caps at half the sidebar; a long item list scrolls inside the
// cap instead of crowding the workspace list off a short screen.
mobileNavScroll: {
flexGrow: 0,
flexShrink: 1,
maxHeight: "50%",
},
mobileNavRowsGroup: {
paddingTop: theme.spacing[2],
gap: 2,
paddingBottom: theme.spacing[1.5],
},
sidebarContent: {
flex: 1,
minHeight: 0,
},
mobileCloseButton: {
marginRight: theme.spacing[2],
},
desktopSidebarBorder: {
borderRightWidth: 1,
mobileCloseButton: (rightInset: number) => ({
marginRight: theme.spacing[2] + rightInset,
}),
desktopSidebarBorder: (hasDivision: boolean) => ({
borderRightWidth: hasDivision ? 0 : 1,
borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surfaceSidebar,
},
}),
sidebarDragArea: {
position: "relative",
},
@@ -1,7 +1,7 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { StyleProp, TextStyle, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { CONTROL_HEIGHTS, type ControlSize } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS, mapLayout, type ControlSize } from "@/components/ui/control-geometry";
import { settingsStyles } from "@/styles/settings";
@@ -49,7 +49,7 @@ const styles = StyleSheet.create((theme) => ({
// trailing button line up.
nestedRow: {
paddingVertical: theme.spacing[2],
minHeight: CONTROL_HEIGHTS.sm + theme.spacing[2] * 2,
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[2] * 2),
},
nestedText: {
fontSize: theme.fontSize.sm,
@@ -154,6 +154,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { useHostBadges } from "@/hosts/use-host-badges";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import { PullRequestStateIcon } from "@/git/pull-request-state-icon";
import { bySizing } from "@/components/ui/control-geometry";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -2737,12 +2738,12 @@ const styles = StyleSheet.create((theme) => ({
marginRight: -6,
},
projectKebabButton: {
// Larger on compact so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px desktop box and the row at its 44px height.
width: { xs: 32, md: 24 },
height: { xs: 32, md: 24 },
marginRight: { xs: -4, md: 0 },
marginVertical: { xs: -4, md: 0 },
// Larger on touch so the kebab is an easy tap target. The negative margins keep its
// dots on the same rail as the 24px pointer box and the row at its 44px height.
width: bySizing({ touch: 32, pointer: 24 }),
height: bySizing({ touch: 32, pointer: 24 }),
marginRight: bySizing({ touch: -4, pointer: 0 }),
marginVertical: bySizing({ touch: -4, pointer: 0 }),
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
@@ -36,6 +36,7 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
import { bySizing } from "@/components/ui/control-geometry";
import {
workspaceServiceLabelKey,
type WorkspaceServiceSummary,
@@ -438,18 +439,18 @@ function triggerStyle({ hovered = false }: PressableStateCallbackType & { hovere
const styles = StyleSheet.create((theme) => ({
trigger: {
// Larger on compact so the kebab is an easy tap target.
width: { xs: 32, md: 18 },
height: { xs: 32, md: 18 },
// Larger on touch so the kebab is an easy tap target.
width: bySizing({ touch: 32, pointer: 18 }),
height: bySizing({ touch: 32, pointer: 18 }),
alignItems: "center",
justifyContent: "center",
borderRadius: 4,
marginLeft: 2,
// Center the compact box on the 20px title line it is anchored to.
marginTop: { xs: -6, md: 0 },
// Center the touch box on the 20px title line it is anchored to.
marginTop: bySizing({ touch: -6, pointer: 0 }),
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
// pull the painted dots through that unused view-box space onto the trailing-content rail.
marginRight: { xs: -14, md: -7 },
marginRight: bySizing({ touch: -14, pointer: -7 }),
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
+9 -9
View File
@@ -64,6 +64,8 @@ import {
import { FloatingSurface } from "@/components/ui/floating";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root";
import { density, densityIconBox } from "@/styles/density";
import { DensityIcon } from "@/components/ui/density-icon";
import { buildDesktopFrameStyle } from "./combobox-frame-style";
export { buildDesktopFrameStyle } from "./combobox-frame-style";
@@ -203,7 +205,7 @@ export function SearchInput({
return (
<View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} />
<DensityIcon icon={Search} size="md" color="foregroundMuted" />
<AdaptiveTextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
@@ -250,21 +252,19 @@ export function ComboboxItem({
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles();
let leadingContent: ReactElement | null = null;
if (leadingSlot) {
leadingContent = <View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>;
} else if (kind === "directory") {
leadingContent = (
<View style={styles.comboboxItemLeadingSlot}>
<Folder size={16} color={theme.colors.foregroundMuted} />
<DensityIcon icon={Folder} size="md" color="foregroundMuted" />
</View>
);
} else if (kind === "file") {
leadingContent = (
<View style={styles.comboboxItemLeadingSlot}>
<File size={16} color={theme.colors.foregroundMuted} />
<DensityIcon icon={File} size="md" color="foregroundMuted" />
</View>
);
}
@@ -308,7 +308,7 @@ export function ComboboxItem({
{selected || trailingSlot ? (
<View style={styles.comboboxItemTrailingContainer}>
<View style={styles.comboboxItemTrailingSlot}>
{selected ? <Check size={16} color={theme.colors.foregroundMuted} /> : null}
{selected ? <DensityIcon icon={Check} size="md" color="foregroundMuted" /> : null}
</View>
{trailingSlot}
</View>
@@ -1682,7 +1682,7 @@ const styles = StyleSheet.create((theme) => ({
comboboxItem: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
minHeight: density.rowHeight,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
@@ -1713,7 +1713,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.55,
},
comboboxItemTrailingSlot: {
width: 16,
width: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
},
@@ -1733,7 +1733,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
comboboxItemLeadingSlot: {
width: 16,
width: densityIconBox("md"),
alignItems: "center",
justifyContent: "center",
},
@@ -1,11 +1,20 @@
import { describe, expect, it } from "vitest";
import {
ALERT_ICON_SIZE,
bySizing,
CONTROL_HEIGHTS,
controlHeightForLayout,
controlSizingForLayout,
createControlGeometry,
HEADER_CONTROL_HEIGHT,
ICON_BUTTON_SIZES,
iconSizeFor,
resolveControlSizing,
switchGeometryForLayout,
getControlInteractionPhase,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
TOUCH_TARGET,
} from "@/components/ui/control-geometry";
import type { Theme } from "@/styles/theme";
@@ -47,6 +56,52 @@ const theme = {
},
} as unknown as Theme;
describe("control sizing gate", () => {
it("is touch-sized in the compact layout or on a touch screen, and pointer-sized otherwise", () => {
expect(resolveControlSizing({ compactLayout: true, touchScreen: true })).toBe("touch");
expect(resolveControlSizing({ compactLayout: true, touchScreen: false })).toBe("touch");
expect(resolveControlSizing({ compactLayout: false, touchScreen: true })).toBe("touch");
expect(resolveControlSizing({ compactLayout: false, touchScreen: false })).toBe("pointer");
});
it("gives a touch screen its touch sizes at every width", () => {
const sizes = { touch: 44, pointer: 32 };
// An iPad: touch-sized in the docked desktop layout too.
expect(bySizing(sizes, true)).toEqual({ xs: 44, md: 44 });
// A mouse-driven window: touch-sized only once it narrows into the compact layout.
expect(bySizing(sizes, false)).toEqual({ xs: 44, md: 32 });
});
it("keeps a device without a touch screen on pointer sizes from md up", () => {
// The unit runtime has no touch screen, which is the desktop that must not change.
expect(controlSizingForLayout(false)).toBe("pointer");
expect(controlSizingForLayout(true)).toBe("touch");
expect(CONTROL_HEIGHTS.md).toEqual({ xs: 44, md: 32 });
expect(CONTROL_HEIGHTS.sm).toEqual({ xs: 32, md: 28 });
expect(HEADER_CONTROL_HEIGHT).toEqual({ xs: 32, md: 26 });
});
it("follows the HIG on touch: a 44pt target, no control under 28pt, larger glyphs", () => {
expect(TOUCH_TARGET).toBe(44);
expect(controlHeightForLayout("md", true)).toBe(TOUCH_TARGET);
expect(ICON_BUTTON_SIZES.large.touch).toBe(TOUCH_TARGET);
expect(ICON_BUTTON_SIZES.small.touch).toBe(TOUCH_TARGET);
// Composer toolbar controls: a full target on touch, unchanged 28 on pointer.
expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 });
expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 });
for (const size of ["sm", "md"] as const) {
expect(controlHeightForLayout(size, true)).toBeGreaterThan(
controlHeightForLayout(size, false),
);
expect(controlHeightForLayout(size, false)).toBeGreaterThanOrEqual(28);
}
for (const size of ["xs", "sm", "md", "lg"] as const) {
expect(iconSizeFor(size).touch).toBeGreaterThan(iconSizeFor(size).pointer);
}
});
});
describe("control geometry", () => {
it("keeps resting control borders transparent while preserving border geometry", () => {
const geometry = createControlGeometry(theme);
@@ -98,18 +153,17 @@ describe("control geometry", () => {
const geometry = createControlGeometry(theme);
const borderWidth = theme.borderWidth[1];
expect(geometry.fieldControlSm.minHeight).toBe(28);
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight);
expect(geometry.fieldControlSm.minHeight).toEqual({ xs: 32, md: 28 });
// The default keeps a 44 touch target in the compact layout and is 32 from the md
// breakpoint up.
expect(geometry.fieldControlMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.fieldControlMd.paddingVertical).toEqual({ xs: 11, md: 5 });
for (const layout of ["xs", "md"] as const) {
expect(
geometry.fieldTextSm.lineHeight +
geometry.fieldControlSm.paddingVertical[layout] * 2 +
borderWidth * 2,
).toBe(geometry.fieldControlSm.minHeight[layout]);
expect(
geometry.fieldTextMd.lineHeight +
geometry.fieldControlMd.paddingVertical[layout] * 2 +
@@ -125,24 +179,25 @@ describe("control geometry", () => {
expect(geometry.segmentedContainerMd.padding).toBe(0);
expect(geometry.segmentedSegmentSm.borderRadius).toBe(geometry.buttonSm.borderRadius);
expect(geometry.segmentedSegmentMd.borderRadius).toBe(geometry.buttonMd.borderRadius);
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerSm.minHeight).toEqual(geometry.buttonSm.minHeight);
expect(geometry.segmentedContainerMd.minHeight).toEqual(geometry.buttonMd.minHeight);
expect(geometry.segmentedSegmentSm.minHeight).toBe(24);
expect(geometry.segmentedSegmentSm.minHeight).toEqual({ xs: 28, md: 24 });
expect(geometry.segmentedSegmentMd.minHeight).toEqual({ xs: 40, md: 28 });
});
it("keeps one size contract across buttons, fields, and segmented controls", () => {
const geometry = createControlGeometry(theme);
expect(geometry.buttonSm.minHeight).toBe(28);
expect(geometry.buttonSm.minHeight).toEqual({ xs: 32, md: 28 });
expect(geometry.buttonMd.minHeight).toEqual({ xs: 44, md: 32 });
expect(geometry.buttonSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.buttonSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
expect(geometry.buttonMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(geometry.fieldControlSm.minHeight).toBe(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlSm.minHeight).toEqual(CONTROL_HEIGHTS.sm);
expect(geometry.fieldControlMd.minHeight).toEqual(CONTROL_HEIGHTS.md);
expect(controlHeightForLayout("md", true)).toBe(44);
expect(controlHeightForLayout("md", false)).toBe(32);
expect(controlHeightForLayout("sm", true)).toBe(28);
expect(controlHeightForLayout("sm", true)).toBe(32);
expect(controlHeightForLayout("sm", false)).toBe(28);
// Same size name means the same label size on every control kind.
expect(geometry.segmentedLabelSm.fontSize).toBe(12);
@@ -177,13 +232,15 @@ describe("control geometry", () => {
thumbTravel: 18,
reservedHeight: 44,
});
// Tight chrome keeps the pointer-sized switch on every layout.
for (const isCompact of [true, false]) {
expect(switchGeometryForLayout("sm", isCompact)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
}
// Tight chrome keeps the pointer-sized track on every layout and reserves its row's height.
expect(switchGeometryForLayout("sm", true)).toMatchObject({
trackWidth: 34,
reservedHeight: 32,
});
expect(switchGeometryForLayout("sm", false)).toMatchObject({
trackWidth: 34,
reservedHeight: 28,
});
});
it("gives alert title and description one font size and a tight gap per size", () => {
@@ -1,5 +1,14 @@
import type { StyleProp, ViewStyle } from "react-native";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
bySizing,
controlSizingForLayout,
mapLayout,
TOUCH_TARGET,
type ControlSizing,
type LayoutValue,
type SizingValue,
} from "./control-sizing";
/**
* Controls come in two sizes. `md` is the default and the right size almost everywhere;
@@ -26,46 +35,78 @@ export interface ControlInteractionStyleMap {
controlDisabled?: StyleProp<ViewStyle>;
}
const SMALL_CONTROL_HEIGHT = 28;
// The default control keeps a full touch target in the compact layout and tightens where a
// pointer drives it.
const DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT = 44;
const DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT = 32;
export const HEADER_CONTROL_HEIGHT = 26;
export {
bySizing,
controlSizingForLayout,
mapLayout,
resolveControlSizing,
TOUCH_TARGET,
type ControlSizing,
type ControlSizingFacts,
type LayoutValue,
type SizingValue,
} from "./control-sizing";
// Touch `md` is the iOS HIG's 44pt default control; touch `sm` stays above its 28pt minimum.
const CONTROL_SIZES = {
sm: { touch: 32, pointer: 28 },
md: { touch: TOUCH_TARGET, pointer: 32 },
} satisfies Record<ControlSize, SizingValue>;
/** Pointer-sized header controls run tighter than `sm` so they fit the 36pt header. */
const POINTER_HEADER_CONTROL_HEIGHT = 26;
const SEGMENTED_INSET = 2;
const CONTROL_FOCUS_RING_WIDTH = 2;
const CONTROL_FOCUS_RING_OFFSET = 1;
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
function byLayout(compact: number, wide: number): LayoutValue {
return { xs: compact, md: wide };
}
function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return byLayout(map(value.xs), map(value.md));
}
/**
* The height of every button, field, and segmented control of a size. Exported so a row that
* hosts one of those controls can size itself from the same numbers instead of guessing a
* height the control then outgrows. `md` depends on the layout, so it only works inside a
* Unistyles stylesheet.
* height the control then outgrows. Breakpoint values, so they only work inside a Unistyles
* stylesheet.
*/
export const CONTROL_HEIGHTS = {
sm: SMALL_CONTROL_HEIGHT,
md: byLayout(DEFAULT_CONTROL_HEIGHT_COMPACT_LAYOUT, DEFAULT_CONTROL_HEIGHT_WIDE_LAYOUT),
} satisfies Record<ControlSize, number | LayoutValue>;
sm: bySizing(CONTROL_SIZES.sm),
md: bySizing(CONTROL_SIZES.md),
} satisfies Record<ControlSize, LayoutValue>;
/** A framed control in a header row, such as a split button. Touch-sized it is an `sm` control. */
export const HEADER_CONTROL_HEIGHT = bySizing({
touch: CONTROL_SIZES.sm.touch,
pointer: POINTER_HEADER_CONTROL_HEIGHT,
});
/**
* Square icon-only buttons. Touch-sized, every icon button is one touch target; pointer-sized,
* header buttons are larger than toolbar buttons.
*/
export const ICON_BUTTON_SIZES = {
large: { touch: TOUCH_TARGET, pointer: POINTER_HEADER_CONTROL_HEIGHT },
small: { touch: TOUCH_TARGET, pointer: 20 },
} satisfies Record<"large" | "small", SizingValue>;
/**
* A control in a dense toolbar row, such as the composer's: an icon-only square or a labelled
* pill. Touch-sized it is one touch target.
*/
export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue;
export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES);
/**
* Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body
* text, so each step is larger.
*/
export type IconSize = keyof typeof ICON_SIZE;
export const TOUCH_ICON_SIZE: Record<IconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
export function iconSizeFor(size: IconSize): SizingValue {
return { touch: TOUCH_ICON_SIZE[size], pointer: ICON_SIZE[size] };
}
/** The height of a control of `size`, for code that has to compute with it outside a stylesheet. */
export function controlHeightForLayout(size: ControlSize, isCompact: boolean): number {
const height = CONTROL_HEIGHTS[size];
if (typeof height === "number") return height;
return isCompact ? height.xs : height.md;
return CONTROL_SIZES[size][controlSizingForLayout(isCompact)];
}
export const controlIconSize: Record<ControlSize, number> = {
@@ -96,7 +137,8 @@ const SWITCH_TOUCH = switchSize(44, 26, 22);
* animation, so the size is resolved to plain numbers here instead of a stylesheet breakpoint.
*/
export function switchGeometryForLayout(size: ControlSize, isCompact: boolean): SwitchGeometry {
const track = size === "md" && isCompact ? SWITCH_TOUCH : SWITCH_POINTER;
const touchSized = controlSizingForLayout(isCompact) === "touch";
const track = size === "md" && touchSized ? SWITCH_TOUCH : SWITCH_POINTER;
return { ...track, reservedHeight: controlHeightForLayout(size, isCompact) };
}
@@ -225,10 +267,8 @@ export function createControlGeometry(theme: Theme) {
const fieldControlSm = {
minHeight: CONTROL_HEIGHTS.sm,
paddingHorizontal: theme.spacing[2],
paddingVertical: fieldVerticalPadding(
CONTROL_HEIGHTS.sm,
fieldTextSmLineHeight,
controlBorderWidth,
paddingVertical: mapLayout(CONTROL_HEIGHTS.sm, (height) =>
fieldVerticalPadding(height, fieldTextSmLineHeight, controlBorderWidth),
),
borderRadius: theme.borderRadius.md,
};
@@ -258,7 +298,7 @@ export function createControlGeometry(theme: Theme) {
},
buttonMd: {
minHeight: CONTROL_HEIGHTS.md,
paddingHorizontal: byLayout(theme.spacing[6], theme.spacing[4]),
paddingHorizontal: bySizing({ touch: theme.spacing[6], pointer: theme.spacing[4] }),
borderRadius: theme.borderRadius.full,
},
// A button with only an icon is a circle of its size's height.
@@ -314,7 +354,7 @@ export function createControlGeometry(theme: Theme) {
padding: 0,
},
segmentedSegmentSm: {
minHeight: CONTROL_HEIGHTS.sm - SEGMENTED_INSET * 2,
minHeight: mapLayout(CONTROL_HEIGHTS.sm, (height) => height - SEGMENTED_INSET * 2),
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.full,
},
@@ -0,0 +1,68 @@
import { hasTouchScreen } from "@/hooks/use-fine-pointer";
// The sizing gate, apart from the control sizes so that low-level modules such as
// `constants/layout` can size by it without loading the theme. Import it through
// `@/components/ui/control-geometry`, which re-exports it.
/**
* Every size comes in a touch value and a pointer value. Touch values follow Apple's Human
* Interface Guidelines for iOS and iPadOS: a 44pt default control and hit region, nothing
* smaller than its 28pt minimum. Pointer values sit one step down the same button ladder.
*/
export type ControlSizing = "touch" | "pointer";
export interface ControlSizingFacts {
/** The layout is below the `md` breakpoint. */
compactLayout: boolean;
/** The device has a touch screen; see `hasTouchScreen`. */
touchScreen: boolean;
}
/**
* The one sizing gate: touch-sized in the compact layout or on any device with a touch screen,
* pointer-sized otherwise. A touch screen keeps touch sizing when a mouse or keyboard is attached.
*/
export function resolveControlSizing({
compactLayout,
touchScreen,
}: ControlSizingFacts): ControlSizing {
if (compactLayout || touchScreen) return "touch";
return "pointer";
}
// Read once, so attaching a mouse or keyboard later never flips the sizing.
const DEVICE_HAS_TOUCH_SCREEN = hasTouchScreen();
/** The sizing on this device for a layout, for code that sizes outside a stylesheet. */
export function controlSizingForLayout(compactLayout: boolean): ControlSizing {
return resolveControlSizing({ compactLayout, touchScreen: DEVICE_HAS_TOUCH_SCREEN });
}
/** A Unistyles breakpoint value: one number in the compact layout, another from `md` up. */
// A mapped type, not an interface: Unistyles checks breakpoint objects against an index
// signature, which interfaces never satisfy.
export type LayoutValue = Record<"xs" | "md", number>;
export type SizingValue = Record<ControlSizing, number>;
/**
* A size as a Unistyles breakpoint value. The compact layout always takes the touch value; from
* `md` up the device decides.
*/
export function bySizing(
sizes: SizingValue,
touchScreen: boolean = DEVICE_HAS_TOUCH_SCREEN,
): LayoutValue {
return {
xs: sizes[resolveControlSizing({ compactLayout: true, touchScreen })],
md: sizes[resolveControlSizing({ compactLayout: false, touchScreen })],
};
}
/** Derives a breakpoint value from another, such as a row as tall as its control plus padding. */
export function mapLayout(value: LayoutValue, map: (height: number) => number): LayoutValue {
return { xs: map(value.xs), md: map(value.md) };
}
/** The smallest touch target, and the size of every touch-sized icon button, row, and bar. */
export const TOUCH_TARGET = 44;
@@ -208,9 +208,9 @@ function WebHoverCard({
)
keyPressed(event);
};
// Only a scroll that moves the trigger detaches the card. A streaming chat beside it does not.
const scrolled = (event: Event) => {
if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node))
close();
if ((event.target as Node).contains(trigger)) close();
};
document.addEventListener("focusin", focusEntered);
document.addEventListener("focusout", focusLeft);
@@ -1,17 +1,24 @@
import { createContext, useCallback, useContext } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import {
bySizing,
controlSizingForLayout,
ICON_BUTTON_SIZES,
TOUCH_ICON_SIZE,
type SizingValue,
} from "@/components/ui/control-geometry";
import { ICON_SIZE } from "@/styles/theme";
export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ui/icon-color";
export type IconButtonChromeSize = "large" | "small";
const SMALL_ICON_BUTTON_SIZE = 20;
/** Width and height of a large icon button on compact layouts. */
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
// Touch-sized, every icon button is one target with one glyph size.
const GLYPH_SIZES = {
large: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.md },
small: { touch: TOUCH_ICON_SIZE.md, pointer: ICON_SIZE.sm },
} satisfies Record<IconButtonChromeSize, SizingValue>;
export interface IconButtonChromeState {
hovered?: boolean;
@@ -22,7 +29,7 @@ export interface IconButtonChromeState {
function resolveIconButtonFrame(size: IconButtonChromeSize, compact: boolean) {
if (size === "large") return styles.large;
return compact ? styles.smallCompact : styles.small;
return controlSizingForLayout(compact) === "touch" ? styles.smallTouch : styles.small;
}
interface IconButtonChromeOptions {
@@ -92,24 +99,17 @@ export function iconButtonChromeFrameStyle(
}
export function iconButtonChromeGlyphSize(size: IconButtonChromeSize, compact = false): number {
if (size === "large") return ICON_SIZE.md;
return compact ? 18 : 14;
return GLYPH_SIZES[size][controlSizingForLayout(compact)];
}
export function smallIconButtonChromeFrameSize(compact = false): number {
return compact ? COMPACT_SMALL_ICON_BUTTON_SIZE : SMALL_ICON_BUTTON_SIZE;
return ICON_BUTTON_SIZES.small[controlSizingForLayout(compact)];
}
const styles = StyleSheet.create((theme) => ({
large: {
width: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT,
},
width: bySizing(ICON_BUTTON_SIZES.large),
height: bySizing(ICON_BUTTON_SIZES.large),
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -119,8 +119,8 @@ const styles = StyleSheet.create((theme) => ({
outlineColor: "transparent",
},
small: {
width: SMALL_ICON_BUTTON_SIZE,
height: SMALL_ICON_BUTTON_SIZE,
width: ICON_BUTTON_SIZES.small.pointer,
height: ICON_BUTTON_SIZES.small.pointer,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -129,9 +129,9 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
smallCompact: {
width: COMPACT_SMALL_ICON_BUTTON_SIZE,
height: COMPACT_SMALL_ICON_BUTTON_SIZE,
smallTouch: {
width: ICON_BUTTON_SIZES.small.touch,
height: ICON_BUTTON_SIZES.small.touch,
padding: 0,
borderRadius: theme.borderRadius.md,
alignItems: "center",
@@ -1,2 +1,5 @@
/** Shared geometry for menu rows and controls that align with them. */
export const MENU_ITEM_HEIGHT = { xs: 40, md: 28 } as const;
import { density } from "@/styles/density";
/** A menu row is a tight row: touch-sized wherever controls are (see `resolveControlSizing`). */
export const MENU_ITEM_HEIGHT = density.tightRowHeight;
@@ -2,6 +2,7 @@ import { forwardRef, useCallback, type ComponentProps } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { ComposerToolbarGlyph } from "@/composer/agent-controls/glyph";
import type { AgentControlIcon } from "@/agent-controls/icons";
@@ -96,7 +97,7 @@ export const AgentControlTrigger = forwardRef<View, AgentControlTriggerProps>(
const styles = StyleSheet.create((theme) => ({
toolbarControl: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -107,7 +108,7 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: "transparent",
},
toolbarIconOnly: {
width: 28,
width: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
@@ -4,7 +4,7 @@ import { StyleSheet, View } from "react-native";
export function ComposerToolbarGlyph({ children, size }: { children: ReactNode; size: number }) {
return (
<View
style={size >= 20 ? styles.native : styles.web}
style={size >= 20 ? styles.touch : styles.pointer}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
@@ -15,14 +15,14 @@ export function ComposerToolbarGlyph({ children, size }: { children: ReactNode;
}
const styles = StyleSheet.create({
web: {
pointer: {
width: 16,
height: 16,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
native: {
touch: {
width: 20,
height: 20,
flexShrink: 0,
@@ -61,6 +61,8 @@ import {
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
import { useIsCompactFormFactor } from "@/constants/layout";
import { controlSizingForLayout, TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { DensityIcon } from "@/components/ui/density-icon";
import { readMeasuredWidth } from "@/hooks/use-container-width";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
@@ -70,11 +72,10 @@ import {
type AgentControlCommandCenterSource,
} from "@/command-center/agent-control-registration";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isNative } from "@/constants/platform";
import {
composerToolbarGeometry,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveComposerToolbarGlyphSize,
type ComposerControlDensity,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
@@ -460,6 +461,10 @@ function buildAgentProviderModels(
return map;
}
function densityFolds(density: ComposerControlDensity): boolean {
return resolveComposerControlPresentation(density).foldControls;
}
function buildOpenChangeHandler(
selector: AgentControlSelector,
setOpenSelector: (next: AgentControlSelector | null) => void,
@@ -503,14 +508,20 @@ function ControlledAgentControls({
modelSelectorServerId = null,
isCompactLayout,
}: ControlledAgentControlsProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompactFormFactor = useIsCompactFormFactor();
const isCompact = isCompactLayout ?? isCompactFormFactor;
// Control sizes follow the window layout, as the toolbar's stylesheet does. A narrow pane in a
// wide window (`isCompactLayout`) changes which labels show, never how large a control is.
const toolbarSizing = controlSizingForLayout(isCompactFormFactor);
const toolbarGeometry = useMemo(() => composerToolbarGeometry(toolbarSizing), [toolbarSizing]);
const { fontScale } = useWindowDimensions();
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<AgentControlSelector | null>(null);
const initialDensity: ComposerControlDensity = isCompact ? "tight" : "full";
// A compact composer always folds its controls into the model sheet; the width budget only
// decides whether the model keeps its label.
const minimumDensity: ComposerControlDensity = isCompact ? "compact" : "full";
const initialDensity = minimumDensity;
const [density, setDensity] = useState<ComposerControlDensity>(initialDensity);
const densityRef = useRef<ComposerControlDensity>(initialDensity);
const availableWidthRef = useRef(0);
@@ -574,10 +585,10 @@ function ControlledAgentControls({
const presentation = useMemo(() => resolveComposerControlPresentation(density), [density]);
const layoutContextValue = useMemo(
() => ({
glyphSize: resolveComposerToolbarGlyphSize(isNative ? "native" : "web"),
glyphSize: toolbarGeometry.glyphSize,
presentation,
}),
[presentation],
[presentation, toolbarGeometry],
);
const updateDensityForWidth = useCallback(
@@ -586,12 +597,20 @@ function ControlledAgentControls({
availableWidth,
currentDensity: densityRef.current,
controls: controlPresence,
geometry: toolbarGeometry,
minimumDensity,
});
if (nextDensity === densityRef.current) return;
// Folding swaps the toolbar controls for the model sheet and back, so whatever menu or
// sheet was open belonged to controls that are gone.
if (densityFolds(nextDensity) !== densityFolds(densityRef.current)) {
setOpenSelector(null);
setActiveSheet(null);
}
densityRef.current = nextDensity;
setDensity(nextDensity);
},
[controlPresence],
[controlPresence, minimumDensity, toolbarGeometry],
);
const handleLayout = useCallback(
@@ -633,10 +652,9 @@ function ControlledAgentControls({
selected={args.selected}
active={args.active}
onPress={args.onPress}
iconColor={theme.colors.foreground}
/>
),
[theme.colors.foreground],
[],
);
const handleOpenChange = useCallback(
@@ -702,8 +720,8 @@ function ControlledAgentControls({
const handleCloseSheet = useCallback(() => {
setActiveSheet(null);
if (!isCompact) onDropdownClose?.();
}, [isCompact, onDropdownClose]);
if (!presentation.foldControls) onDropdownClose?.();
}, [onDropdownClose, presentation.foldControls]);
const handleSelectThinkingAndClose = useCallback(
(thinkingOptionId: string) => {
@@ -734,7 +752,7 @@ function ControlledAgentControls({
return (
<ComposerControlLayoutProvider value={layoutContextValue}>
<View style={styles.container} onLayout={handleLayout}>
{!isCompact ? (
{!presentation.foldControls ? (
<DesktopAgentControlsContent
provider={provider}
providerOptions={providerOptions}
@@ -821,6 +839,7 @@ function ControlledAgentControls({
renderThinkingOption={renderThinkingOption}
modeControl={modeControl}
glyphSize={layoutContextValue.glyphSize}
showLabels={presentation.showModelLabel}
modelSelectorServerId={modelSelectorServerId}
canSwitchProvider={Boolean(onSelectProviderAndModel)}
/>
@@ -1144,6 +1163,7 @@ interface SheetAgentControlsContentProps {
}) => ReactElement;
modeControl?: AgentModeControlValue | null;
glyphSize: number;
showLabels: boolean;
modelSelectorServerId: string | null;
canSwitchProvider: boolean;
}
@@ -1183,6 +1203,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
renderThinkingOption,
modeControl,
glyphSize,
showLabels,
modelSelectorServerId,
canSwitchProvider,
} = props;
@@ -1271,6 +1292,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
isRetryingProvider={isRetryingModelProvider}
serverId={modelSelectorServerId}
glyphSize={glyphSize}
showLabels={showLabels}
canSwitchProvider={canSwitchProvider}
>
{sheetControls}
@@ -1529,15 +1551,16 @@ function ThinkingComboboxOption({
selected,
active,
onPress,
iconColor,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
iconColor: string;
}) {
const leadingSlot = useMemo(() => <ThinkingIcon size={16} color={iconColor} />, [iconColor]);
const leadingSlot = useMemo(
() => <DensityIcon icon={ThinkingIcon} size="md" color="foreground" />,
[],
);
return (
<ComboboxItem
label={option.label}
@@ -1953,7 +1976,7 @@ const styles = StyleSheet.create((theme) => ({
overflow: "hidden",
},
modeBadge: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
backgroundColor: "transparent",
@@ -1971,8 +1994,8 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
modeIconBadge: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 0,
@@ -1,5 +1,8 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ComposerControlPresentation } from "@/composer/agent-controls/layout";
import {
resolveComposerControlPresentation,
type ComposerControlPresentation,
} from "@/composer/agent-controls/layout";
interface ComposerControlLayoutValue {
glyphSize: number;
@@ -8,12 +11,7 @@ interface ComposerControlLayoutValue {
const DEFAULT_LAYOUT: ComposerControlLayoutValue = {
glyphSize: 16,
presentation: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
},
presentation: resolveComposerControlPresentation("full"),
};
const ComposerControlLayoutContext = createContext(DEFAULT_LAYOUT);
@@ -1,30 +1,42 @@
import { describe, expect, it } from "vitest";
import {
COMPOSER_TOOLBAR_GEOMETRY,
composerToolbarGeometry,
resolveComposerControlDensity,
resolveComposerControlPresentation,
resolveComposerToolbarGlyphSize,
} from "./layout";
const POINTER = composerToolbarGeometry("pointer");
const TOUCH = composerToolbarGeometry("touch");
describe("composer control layout", () => {
it("removes labels in priority order as the toolbar narrows", () => {
it("removes labels, then folds controls, then drops the model label as the toolbar narrows", () => {
expect(resolveComposerControlPresentation("full")).toEqual({
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
foldControls: false,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("condensed")).toEqual({
expect(resolveComposerControlPresentation("condensed")).toMatchObject({
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
foldControls: false,
});
expect(resolveComposerControlPresentation("tight")).toEqual({
showCarets: false,
showThinkingLabel: false,
expect(resolveComposerControlPresentation("tight")).toMatchObject({
showModeLabel: false,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("compact")).toMatchObject({
foldControls: true,
showModelLabel: true,
});
expect(resolveComposerControlPresentation("iconOnly")).toMatchObject({
foldControls: true,
showModelLabel: false,
});
});
@@ -41,6 +53,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 420,
currentDensity: "full",
geometry: POINTER,
controls,
}),
).toBe("full");
@@ -48,6 +61,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 380,
currentDensity: "full",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -55,6 +69,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 290,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -62,6 +77,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 280,
currentDensity: "condensed",
geometry: POINTER,
controls,
}),
).toBe("tight");
@@ -69,6 +85,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 300,
currentDensity: "tight",
geometry: POINTER,
controls,
}),
).toBe("tight");
@@ -76,6 +93,7 @@ describe("composer control layout", () => {
resolveComposerControlDensity({
availableWidth: 312,
currentDensity: "tight",
geometry: POINTER,
controls,
}),
).toBe("condensed");
@@ -85,6 +103,7 @@ describe("composer control layout", () => {
const base = {
availableWidth: 430,
currentDensity: "condensed" as const,
geometry: POINTER,
};
expect(
@@ -129,6 +148,7 @@ describe("composer control layout", () => {
const base = {
availableWidth: 430,
currentDensity: "full" as const,
geometry: POINTER,
controls: {
hasModel: true,
hasThinking: true,
@@ -154,15 +174,97 @@ describe("composer control layout", () => {
).toBe("condensed");
});
it("gives every toolbar control one shell and one platform glyph envelope", () => {
expect(COMPOSER_TOOLBAR_GEOMETRY).toEqual({
it("gives every toolbar control one shell and one glyph per control sizing", () => {
expect(POINTER).toEqual({
controlSize: 28,
glyphSize: 16,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
});
expect(resolveComposerToolbarGlyphSize("web")).toBe(16);
expect(resolveComposerToolbarGlyphSize("native")).toBe(20);
expect(TOUCH).toMatchObject({ controlSize: 44, glyphSize: 20 });
});
it("budgets touch-sized controls, so a touch toolbar condenses at a width a pointer one fits", () => {
const input = {
availableWidth: 420,
currentDensity: "full" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }],
fontScale: 1,
},
};
expect(resolveComposerControlDensity({ ...input, geometry: POINTER })).toBe("full");
expect(resolveComposerControlDensity({ ...input, geometry: TOUCH })).toBe("condensed");
});
describe("touch toolbar on an iPad in portrait, sidebar docked", () => {
// Codex: model, thinking, mode and two toggle features. The measured toolbar is 272 wide
// with an empty draft and 224 once an attachment brings in the send button.
const controls = {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [{ type: "toggle" as const }, { type: "toggle" as const }],
fontScale: 1,
};
it("keeps the model family label with icon-only controls on an empty draft", () => {
expect(
resolveComposerControlDensity({
availableWidth: 272,
currentDensity: "full",
geometry: TOUCH,
controls,
}),
).toBe("tight");
});
it("folds thinking, mode and features into the model control once an attachment narrows it", () => {
expect(
resolveComposerControlDensity({
availableWidth: 224,
currentDensity: "tight",
geometry: TOUCH,
controls,
}),
).toBe("compact");
});
it("drops the model label only after the controls are folded", () => {
expect(
resolveComposerControlDensity({
availableWidth: 120,
currentDensity: "compact",
geometry: TOUCH,
controls,
}),
).toBe("iconOnly");
});
});
it("starts a compact composer at the folded toolbar however wide it is", () => {
const input = {
currentDensity: "compact" as const,
geometry: TOUCH,
minimumDensity: "compact" as const,
controls: {
hasModel: true,
hasThinking: true,
hasMode: true,
features: [],
fontScale: 1,
},
};
expect(resolveComposerControlDensity({ ...input, availableWidth: 600 })).toBe("compact");
// The iPhone toolbar with an attachment: "GPT-6…" beside "Medium" still fits.
expect(resolveComposerControlDensity({ ...input, availableWidth: 159 })).toBe("compact");
expect(resolveComposerControlDensity({ ...input, availableWidth: 120 })).toBe("iconOnly");
});
});
@@ -1,4 +1,23 @@
export type ComposerControlDensity = "full" | "condensed" | "tight";
import {
iconSizeFor,
TOOLBAR_CONTROL_SIZES,
type ControlSizing,
} from "@/components/ui/control-geometry";
/**
* How much of the toolbar shows, widest first. Each step drops something before the next:
* carets and labels, then the mode label, then the thinking, mode and feature controls fold into
* the model control's sheet (`compact`, the phone toolbar), then the model's own label.
*/
export type ComposerControlDensity = "full" | "condensed" | "tight" | "compact" | "iconOnly";
const DENSITY_LADDER: readonly ComposerControlDensity[] = [
"full",
"condensed",
"tight",
"compact",
"iconOnly",
];
export interface ComposerControlPresence {
hasModel: boolean;
@@ -15,27 +34,46 @@ export interface ComposerControlPresentation {
showThinkingLabel: boolean;
showModeLabel: boolean;
aggregateFeatures: boolean;
/** Thinking, mode and features live in the model control's sheet. */
foldControls: boolean;
showModelLabel: boolean;
}
export const COMPOSER_TOOLBAR_GEOMETRY = {
controlSize: 28,
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
} as const;
export interface ComposerToolbarGeometry {
controlSize: number;
glyphSize: number;
controlGap: number;
iconLabelGap: number;
labelPadding: number;
caretSize: number;
}
/** The toolbar's measurements under a control sizing; see `resolveControlSizing`. */
export function composerToolbarGeometry(sizing: ControlSizing): ComposerToolbarGeometry {
return {
controlSize: TOOLBAR_CONTROL_SIZES[sizing],
glyphSize: iconSizeFor("md")[sizing],
controlGap: 4,
iconLabelGap: 4,
labelPadding: 8,
caretSize: 14,
};
}
const DENSITY_HYSTERESIS = 12;
/** The model label's narrowest useful width: enough for the model family, such as "GPT-6…". */
const MODEL_FAMILY_LABEL_WIDTH = 56;
/** A thinking label beside the folded model label, such as "Medium". */
const THINKING_LABEL_WIDTH = 48;
function normalizedFontScale(fontScale: number): number {
return Number.isFinite(fontScale) ? Math.max(1, fontScale) : 1;
}
function sumControlWidths(widths: number[]): number {
function sumControlWidths(widths: number[], geometry: ComposerToolbarGeometry): number {
if (widths.length === 0) return 0;
return (
widths.reduce((total, width) => total + width, 0) +
(widths.length - 1) * COMPOSER_TOOLBAR_GEOMETRY.controlGap
widths.reduce((total, width) => total + width, 0) + (widths.length - 1) * geometry.controlGap
);
}
@@ -43,92 +81,173 @@ function estimateLabelWidth(label: string, fontScale: number): number {
return Array.from(label).length * 7 * fontScale;
}
/** A labelled control's fixed chrome: its padding, its glyph, and the gap to its label. */
function labelledChrome(geometry: ComposerToolbarGeometry): number {
return geometry.labelPadding * 2 + geometry.glyphSize + geometry.iconLabelGap;
}
function resolveFeatureControlWidth(
feature: ComposerFeatureControlPresence,
fontScale: number,
geometry: ComposerToolbarGeometry,
): number {
if (feature.type === "toggle") return COMPOSER_TOOLBAR_GEOMETRY.controlSize;
if (feature.type === "toggle") return geometry.controlSize;
return (
COMPOSER_TOOLBAR_GEOMETRY.controlSize +
COMPOSER_TOOLBAR_GEOMETRY.iconLabelGap +
COMPOSER_TOOLBAR_GEOMETRY.labelPadding * 2 +
geometry.controlSize +
geometry.iconLabelGap +
geometry.labelPadding * 2 +
estimateLabelWidth(feature.label, fontScale)
);
}
function resolveCondensedFloor(controls: ComposerControlPresence): number {
function resolveCondensedFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const widths: number[] = [];
if (controls.hasModel) widths.push(36 + 60 * fontScale);
if (controls.hasThinking) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
if (controls.hasMode) widths.push(36 + 96 * fontScale);
if (controls.features.length > 0) widths.push(COMPOSER_TOOLBAR_GEOMETRY.controlSize);
return sumControlWidths(widths);
if (controls.hasModel) widths.push(chrome + 60 * fontScale);
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(chrome + 96 * fontScale);
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
function resolveFullFloor(controls: ComposerControlPresence): number {
function resolveFullFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const chrome = labelledChrome(geometry);
const caret = geometry.caretSize;
const widths: number[] = [];
if (controls.hasModel) widths.push(50 + 70 * fontScale);
if (controls.hasThinking) widths.push(54 + 48 * fontScale);
if (controls.hasMode) widths.push(54 + 96 * fontScale);
if (controls.hasModel) widths.push(chrome + caret + 70 * fontScale);
if (controls.hasThinking) widths.push(chrome + geometry.iconLabelGap + caret + 48 * fontScale);
if (controls.hasMode) widths.push(chrome + geometry.iconLabelGap + caret + 96 * fontScale);
for (const feature of controls.features) {
widths.push(resolveFeatureControlWidth(feature, fontScale));
widths.push(resolveFeatureControlWidth(feature, fontScale, geometry));
}
return sumControlWidths(widths);
return sumControlWidths(widths, geometry);
}
/** Model at its family label, with thinking, mode and features as icon-only squares. */
function resolveTightFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const widths: number[] = [];
if (controls.hasModel) {
widths.push(labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale);
}
if (controls.hasThinking) widths.push(geometry.controlSize);
if (controls.hasMode) widths.push(geometry.controlSize);
if (controls.features.length > 0) widths.push(geometry.controlSize);
return sumControlWidths(widths, geometry);
}
/** One model control carrying the model family label and the thinking label. */
function resolveCompactFloor(
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
const fontScale = normalizedFontScale(controls.fontScale);
const thinkingLabel = controls.hasThinking
? geometry.iconLabelGap + THINKING_LABEL_WIDTH * fontScale
: 0;
return labelledChrome(geometry) + MODEL_FAMILY_LABEL_WIDTH * fontScale + thinkingLabel;
}
function resolveDensityFloor(
density: ComposerControlDensity,
controls: ComposerControlPresence,
geometry: ComposerToolbarGeometry,
): number {
switch (density) {
case "full":
return resolveFullFloor(controls, geometry);
case "condensed":
return resolveCondensedFloor(controls, geometry);
case "tight":
return resolveTightFloor(controls, geometry);
case "compact":
return resolveCompactFloor(controls, geometry);
case "iconOnly":
return 0;
}
}
/**
* The widest density whose floor fits `availableWidth`. Widening past the current density needs
* the floor plus the hysteresis and keeping it tolerates the floor minus it, so a width near a
* floor does not flip back and forth. `minimumDensity` is the widest density allowed.
*/
export function resolveComposerControlDensity(input: {
availableWidth: number;
currentDensity: ComposerControlDensity;
controls: ComposerControlPresence;
geometry: ComposerToolbarGeometry;
minimumDensity?: ComposerControlDensity;
}): ComposerControlDensity {
const fullFloor = resolveFullFloor(input.controls);
const condensedFloor = resolveCondensedFloor(input.controls);
if (input.currentDensity === "full") {
if (input.availableWidth >= fullFloor - DENSITY_HYSTERESIS) return "full";
return input.availableWidth >= condensedFloor ? "condensed" : "tight";
const current = DENSITY_LADDER.indexOf(input.currentDensity);
const first = DENSITY_LADDER.indexOf(input.minimumDensity ?? "full");
for (let step = first; step < DENSITY_LADDER.length; step += 1) {
const density = DENSITY_LADDER[step];
const floor = resolveDensityFloor(density, input.controls, input.geometry);
let required = floor;
if (step < current) required = floor + DENSITY_HYSTERESIS;
if (step === current) required = floor - DENSITY_HYSTERESIS;
if (input.availableWidth >= required) return density;
}
if (input.currentDensity === "condensed") {
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth < condensedFloor - DENSITY_HYSTERESIS) return "tight";
return "condensed";
}
if (input.availableWidth >= fullFloor + DENSITY_HYSTERESIS) return "full";
if (input.availableWidth >= condensedFloor + DENSITY_HYSTERESIS) return "condensed";
return "tight";
return "iconOnly";
}
export function resolveComposerControlPresentation(
density: ComposerControlDensity,
): ComposerControlPresentation {
if (density === "full") {
return {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
};
}
if (density === "condensed") {
return {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
};
}
return {
const PRESENTATIONS: Record<ComposerControlDensity, ComposerControlPresentation> = {
full: {
showCarets: true,
showThinkingLabel: true,
showModeLabel: true,
aggregateFeatures: false,
foldControls: false,
showModelLabel: true,
},
condensed: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: true,
aggregateFeatures: true,
foldControls: false,
showModelLabel: true,
},
tight: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
};
}
foldControls: false,
showModelLabel: true,
},
compact: {
showCarets: false,
showThinkingLabel: true,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: true,
},
iconOnly: {
showCarets: false,
showThinkingLabel: false,
showModeLabel: false,
aggregateFeatures: true,
foldControls: true,
showModelLabel: false,
},
};
export function resolveComposerToolbarGlyphSize(platform: "web" | "native"): number {
return platform === "native" ? 20 : 16;
export function resolveComposerControlPresentation(
density: ComposerControlDensity,
): ComposerControlPresentation {
return PRESENTATIONS[density];
}
@@ -27,6 +27,7 @@ import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import type { AgentMode } from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { getAgentModeIcon, getAgentModeOptionIcon } from "@/agent-controls/icons";
import { DensityIcon } from "@/components/ui/density-icon";
interface ModeComboboxOptionProps {
option: ComboboxOption;
selected: boolean;
@@ -34,7 +35,6 @@ interface ModeComboboxOptionProps {
onPress: () => void;
provider: string;
providerDefinitions: AgentProviderDefinition[];
iconColor: string;
}
function ModeComboboxOption({
@@ -44,12 +44,12 @@ function ModeComboboxOption({
onPress,
provider,
providerDefinitions,
iconColor,
}: ModeComboboxOptionProps) {
const IconComponent = getAgentModeOptionIcon(provider, option.id, providerDefinitions);
const leadingSlot = useMemo(
() => (IconComponent ? <IconComponent size={16} color={iconColor} /> : null),
[IconComponent, iconColor],
() =>
IconComponent ? <DensityIcon icon={IconComponent} size="md" color="foreground" /> : null,
[IconComponent],
);
return (
<ComboboxItem
@@ -171,10 +171,9 @@ export function AgentModeControl({
onPress={args.onPress}
provider={provider}
providerDefinitions={providerDefinitions}
iconColor={theme.colors.foreground}
/>
),
[provider, providerDefinitions, theme.colors.foreground],
[provider, providerDefinitions],
);
const sheetHeader = useMemo<SheetHeader>(
@@ -15,6 +15,7 @@ import { resolveModelSheetOpening } from "@/composer/agent-controls/model-sheet-
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { TOOLBAR_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
const SNAP_POINTS = ["80%", "90%"];
const MODEL_LIST_TOP_INSET = 4;
@@ -42,6 +43,8 @@ interface CompactModelSheetProps {
disabled?: boolean;
serverId?: string | null;
glyphSize: number;
/** False when the toolbar is too narrow for labels: the trigger shows the provider glyph only. */
showLabels: boolean;
canSwitchProvider: boolean;
children: ReactNode;
}
@@ -94,6 +97,7 @@ export function CompactModelSheet({
disabled = false,
serverId = null,
glyphSize,
showLabels,
canSwitchProvider,
children,
}: CompactModelSheetProps) {
@@ -229,14 +233,16 @@ export function CompactModelSheet({
open();
}, [close, isOpen, open]);
const iconOnly = !showLabels && hasSelectedProvider;
const triggerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.trigger,
iconOnly && styles.triggerIconOnly,
hovered && styles.triggerHovered,
(pressed || isOpen) && styles.triggerPressed,
disabled && styles.triggerDisabled,
],
[disabled, isOpen],
[disabled, iconOnly, isOpen],
);
const mobileRootContent = useMemo(
() => (
@@ -280,16 +286,18 @@ export function CompactModelSheet({
<ModelIcon size={glyphSize} color={styles.providerIcon.color} />
</ComposerToolbarGlyph>
) : null}
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
{!iconOnly ? (
<View style={styles.triggerLabels}>
<Text style={styles.triggerText} numberOfLines={1}>
{shortModelLabel(rootBrowser.triggerLabel)}
</Text>
) : null}
</View>
{thinkingLabel ? (
<Text style={styles.triggerThinking} numberOfLines={1}>
{thinkingLabel}
</Text>
) : null}
</View>
) : null}
</ComboboxTrigger>
<AdaptiveModalSheet
@@ -374,7 +382,7 @@ export function CompactModelSheet({
const styles = StyleSheet.create((theme) => ({
trigger: {
height: 28,
height: TOOLBAR_CONTROL_HEIGHT,
minWidth: 0,
flexShrink: 1,
flexDirection: "row",
@@ -384,6 +392,12 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius["2xl"],
backgroundColor: "transparent",
},
triggerIconOnly: {
width: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
paddingHorizontal: 0,
justifyContent: "center",
},
triggerHovered: {
backgroundColor: theme.colors.surface2,
},
+14 -14
View File
@@ -31,6 +31,11 @@ import {
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
} from "@/components/ui/control-geometry";
import { usePanelStore } from "@/stores/panel-store";
import { useHasFinePointer } from "@/hooks/use-fine-pointer";
import { useShallow } from "zustand/shallow";
@@ -193,10 +198,6 @@ const EMPTY_ATTACHMENT_SCOPE_KEYS: readonly string[] = [];
function noop() {}
const noopCallback = () => {};
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
}
function resolveIsComposerLocked(
submitBehavior: "clear" | "preserve-and-lock",
isSubmitLoading: boolean,
@@ -1281,7 +1282,6 @@ function ComposerContentImpl({
}: ComposerContentProps) {
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1310,6 +1310,7 @@ function ComposerContentImpl({
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
const buttonIconSize = iconSizeFor("md")[controlSizingForLayout(isCompactFormFactor)];
const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor);
const hasFinePointer = useHasFinePointer();
const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout);
@@ -2081,7 +2082,6 @@ function ComposerContentImpl({
],
);
const contextWindowMeterGlyphSize = isCompactLayout ? ICON_SIZE.md : buttonIconSize;
const beforeVoiceContent = useMemo(
() =>
hasAgent ? (
@@ -2092,7 +2092,7 @@ function ComposerContentImpl({
maxTokens={agentState.contextWindowMaxTokens}
usedTokens={agentState.contextWindowUsedTokens}
totalCostUsd={agentState.totalCostUsd}
glyphSize={contextWindowMeterGlyphSize}
glyphSize={buttonIconSize}
/>
</View>
) : null,
@@ -2103,7 +2103,7 @@ function ComposerContentImpl({
agentState.contextWindowMaxTokens,
agentState.contextWindowUsedTokens,
agentState.totalCostUsd,
contextWindowMeterGlyphSize,
buttonIconSize,
],
);
@@ -2598,8 +2598,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[3],
},
cancelButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
@@ -2612,15 +2612,15 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
realtimeVoiceButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
+18 -11
View File
@@ -27,7 +27,12 @@ import {
} from "react";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import {
controlSizingForLayout,
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
} from "@/components/ui/control-geometry";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import * as Haptics from "expo-haptics";
import { useDictation } from "@/hooks/use-dictation";
@@ -1282,7 +1287,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const isFullscreen = useComposerFullscreen(editingSession);
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const controlSizing = controlSizingForLayout(isCompact);
const buttonIconSize = iconSizeFor("md")[controlSizing];
const toast = useToast();
const voice = useVoiceOptional();
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
@@ -2235,6 +2241,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
controlSizing={controlSizing}
sendIconSize={buttonIconSize}
surface={textSurface}
toolbar={toolbar}
@@ -2384,21 +2391,21 @@ const styles = StyleSheet.create((theme: Theme) => ({
gap: theme.spacing[1],
},
attachButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
attachButtonAnchor: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -2407,8 +2414,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
backgroundColor: theme.colors.destructive,
},
sendButton: {
width: 28,
height: 28,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: "center",
@@ -2417,7 +2424,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
},
sendButtonLabeled: {
width: "auto",
minWidth: 28,
minWidth: TOOLBAR_CONTROL_HEIGHT,
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.full,
},
@@ -6,14 +6,16 @@ import { Button } from "@/components/ui/button";
import { ComposerFullscreenBody, ComposerFullscreenFooter } from "@/composer/dock";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { isWeb } from "@/constants/platform";
import { ICON_SIZE } from "@/styles/theme";
import {
iconSizeFor,
TOOLBAR_CONTROL_HEIGHT,
TOOLBAR_CONTROL_SIZES,
type ControlSizing,
} from "@/components/ui/control-geometry";
import { StyleSheet } from "react-native-unistyles";
import { useComposerFullscreenExit } from "./fullscreen-exit";
import { resolveGlyphRailOffset } from "./state";
const CONTROL_SIZE = 28;
const FULLSCREEN_GLYPH_SIZE = ICON_SIZE.md;
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
@@ -27,6 +29,7 @@ function keepEditorFocused(event: import("react-native").PointerEvent) {
*/
export function ComposerInputPresentation({
fullscreen,
controlSizing,
sendIconSize,
surface,
toolbar,
@@ -42,6 +45,8 @@ export function ComposerInputPresentation({
overlay,
}: {
fullscreen: boolean;
/** The sizing the toolbar's stylesheet resolves to; see `resolveControlSizing`. */
controlSizing: ControlSizing;
sendIconSize: number;
surface: ReactNode;
toolbar: ReactNode;
@@ -61,18 +66,19 @@ export function ComposerInputPresentation({
// action (Escape, hardware back) belongs only to the pane the user is in.
const { isActiveComposer } = useComposerKeyboardScope();
const exitScopeRef = useComposerFullscreenExit(fullscreen && isActiveComposer, onExit);
const glyphSize = iconSizeFor("md")[controlSizing];
const railOffset = resolveGlyphRailOffset({
controlSize: CONTROL_SIZE,
controlSize: TOOLBAR_CONTROL_SIZES[controlSizing],
sendIconSize,
glyphIconSize: FULLSCREEN_GLYPH_SIZE,
glyphIconSize: glyphSize,
});
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
(color: string) => <Maximize2 color={color} size={glyphSize} />,
[glyphSize],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={FULLSCREEN_GLYPH_SIZE} />,
[],
(color: string) => <Minimize2 color={color} size={glyphSize} />,
[glyphSize],
);
// The toolbar row bleeds 6px past the input padding; the inline glyph sits on that edge.
const enterStyle = useMemo(() => [styles.enterButton, { right: -6 + railOffset }], [railOffset]);
@@ -141,8 +147,8 @@ const styles = StyleSheet.create((theme) => ({
inline: { flexShrink: 1, position: "relative" },
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: CONTROL_SIZE,
height: CONTROL_SIZE,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
@@ -150,8 +156,8 @@ const styles = StyleSheet.create((theme) => ({
enterButton: {
position: "absolute",
top: 0,
width: CONTROL_SIZE,
height: CONTROL_SIZE,
width: TOOLBAR_CONTROL_HEIGHT,
height: TOOLBAR_CONTROL_HEIGHT,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
+9 -6
View File
@@ -13,6 +13,7 @@ import {
type MenuTriggerState,
} from "@/components/ui/menu";
import { StatusRing } from "@/components/status-ring";
import { bySizing } from "@/components/ui/control-geometry";
import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry";
import { isWeb } from "@/constants/platform";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -29,8 +30,9 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
* on every platform.
*
* The bar floats over the transcript with no background, so content remains visible underneath.
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
* the final footer clear without turning the overlay into a layout band.
* Its host gives the scroll viewport a small bottom inset for as long as the bar is mounted, with
* or without pills: that keeps the final footer clear, and pills that come and go every turn do not
* move the transcript.
*/
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
return (
@@ -342,16 +344,17 @@ const styles = StyleSheet.create((theme) => {
// The rail every panel row sits on: inset from the panel edge so the fill is a rounded block
// inside it, and tall enough that revealing an action button cannot resize the row.
//
// Compact and desktop differ here and only here. A finger needs the taller row a menu row gets
// on compact; a pointer does not, and the popover keeps its density.
// Touch and pointer sizing differ here and only here. A finger needs the row a menu row gets
// on touch (`MENU_ITEM_HEIGHT` is always its touch value at `xs`); a pointer does not, and the
// popover keeps its density.
const trackRow = {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: { xs: MENU_ITEM_HEIGHT.xs, md: 32 },
minHeight: bySizing({ touch: MENU_ITEM_HEIGHT.xs, pointer: 32 }),
marginHorizontal: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: { xs: theme.spacing[2], md: theme.spacing[1] },
paddingVertical: bySizing({ touch: theme.spacing[2], pointer: theme.spacing[1] }),
borderRadius: theme.borderRadius.md,
} as const;
+8 -3
View File
@@ -1,14 +1,19 @@
import { useUnistyles } from "react-native-unistyles";
import { bySizing, TOUCH_TARGET } from "@/components/ui/control-sizing";
import { isWeb } from "@/constants/platform";
export const FOOTER_HEIGHT = 75;
// Shared header inner height (excluding safe area insets and border)
// A bar of icon buttons: touch-sized it is one touch target tall, like an iOS navigation bar.
const BAR_HEIGHT = { touch: TOUCH_TARGET, pointer: 36 };
// Shared header inner height from `md` up (excluding safe area insets and border).
// Used by both agent header (ScreenHeader) and explorer sidebar header
// This ensures both headers have the same visual height
export const HEADER_INNER_HEIGHT = 36;
export const HEADER_INNER_HEIGHT = bySizing(BAR_HEIGHT).md;
export const HEADER_INNER_HEIGHT_MOBILE = 56;
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = 36;
/** The tab rail and pane toolbars under the header. A breakpoint value for stylesheets. */
export const WORKSPACE_SECONDARY_HEADER_HEIGHT = bySizing(BAR_HEIGHT);
export const HEADER_TOP_PADDING_MOBILE = 8;
// Measured composer width; independent of the window's compact/wide breakpoint.
+6 -21
View File
@@ -17,7 +17,7 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { actionsWithoutPrimary, type GitAction, type GitActions } from "@/git/policy";
import { useGitActionRunner } from "@/git/use-actions";
import { CONTROL_HEIGHTS, HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -272,10 +272,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,
@@ -296,10 +293,7 @@ const styles = StyleSheet.create((theme) => ({
xs: 48,
md: 42,
},
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: HEADER_CONTROL_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
@@ -330,24 +324,15 @@ 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],
borderLeftColor: theme.colors.borderAccent,
},
iconButton: {
width: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: CONTROL_HEIGHTS.sm,
md: HEADER_CONTROL_HEIGHT,
},
width: HEADER_CONTROL_HEIGHT,
height: HEADER_CONTROL_HEIGHT,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
@@ -2,7 +2,7 @@ import React, { type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { CountedCheckPresentation } from "@/git/check-presentation";
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
import {
CheckPresentationIcon,
getCheckPresentationTone,
@@ -164,10 +164,10 @@ export const sectionKitStyles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
// Fixed, not minHeight plus padding. A row's trailing slot holds an sm Button
// (28pt), which stacked on the vertical padding and made rows with an
// , which stacked on the vertical padding and made rows with an
// "Add to chat" button taller than rows without one. The row is single-line by
// construction, so it declares a height the button fits inside instead.
height: CONTROL_HEIGHTS.sm + theme.spacing[1],
height: mapLayout(CONTROL_HEIGHTS.sm, (height) => height + theme.spacing[1]),
},
checkName: {
fontSize: theme.fontSize.base,
@@ -76,7 +76,7 @@ export function useAggregatedAgents(options?: {
}
const serverLabel = serverLabelById.get(serverId) ?? serverId;
for (const agent of agents.values()) {
if (!includeArchived && agent.archivedAt) {
if (agent.background || (!includeArchived && agent.archivedAt)) {
continue;
}
const nextAgent: AggregatedAgent = {
@@ -18,6 +18,16 @@ export function hasFinePointer(): boolean {
return getFinePointerQueryList()?.matches ?? false;
}
/**
* True when the device has a touch screen, even while a mouse or keyboard is attached to it:
* the primary pointer is coarse, or the screen reports touch points. Always true on native.
*/
export function hasTouchScreen(): boolean {
if (!isWeb) return true;
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(pointer: coarse)").matches || window.navigator.maxTouchPoints > 0;
}
/**
* Reactive `hasFinePointer()` — re-renders when the user switches between a
* mouse and touch (detachable keyboards, tablet mode).
+10 -9
View File
@@ -2384,7 +2384,7 @@ export const en = {
installing: "Installing…",
logs: {
action: "Logs",
title: "Logs: {{id}}",
title: "Logs: {{name}}",
loading: "Loading logs…",
refresh: "Refresh",
refreshing: "Refreshing…",
@@ -2393,7 +2393,8 @@ export const en = {
},
status: { running: "running", disabled: "disabled", failed: "failed" },
actions: {
menu: "Actions for {{id}}",
menu: "Actions for {{name}}",
settings: "Settings",
reload: "Reload",
reloading: "Reloading…",
enable: "Enable",
@@ -2404,16 +2405,16 @@ export const en = {
removing: "Removing…",
},
feedback: {
installed: "Installed {{id}}",
reload: "Reloaded {{id}}",
enable: "Enabled {{id}}",
disable: "Disabled {{id}}",
remove: "Removed {{id}}",
kept: "Kept {{id}}",
installed: "Installed {{name}}",
reload: "Reloaded {{name}}",
enable: "Enabled {{name}}",
disable: "Disabled {{name}}",
remove: "Removed {{name}}",
kept: "Kept {{name}}",
globalEnabled: "Plugins enabled",
globalDisabled: "Plugins disabled",
},
removeConfirmTitle: "Remove {{id}}?",
removeConfirmTitle: "Remove {{name}}?",
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
states: {
offlineTitle: "Plugin host is offline",
@@ -1,7 +1,7 @@
const shared = {
logs: {
action: "Logs",
title: "Logs: {{id}}",
title: "Logs: {{name}}",
loading: "Loading logs…",
refresh: "Refresh",
refreshing: "Refreshing…",
@@ -33,7 +33,8 @@ export const pluginSettings = {
installing: "جارٍ التثبيت…",
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
actions: {
menu: "إجراءات {{id}}",
menu: "إجراءات {{name}}",
settings: "الإعدادات",
reload: "إعادة تحميل",
reloading: "جارٍ إعادة التحميل…",
enable: "تفعيل",
@@ -44,16 +45,16 @@ export const pluginSettings = {
removing: "جارٍ الإزالة…",
},
feedback: {
installed: "تم تثبيت {{id}}",
reload: "تمت إعادة تحميل {{id}}",
enable: "تم تفعيل {{id}}",
disable: "تم تعطيل {{id}}",
remove: "تمت إزالة {{id}}",
kept: "تم الإبقاء على {{id}}",
installed: "تم تثبيت {{name}}",
reload: "تمت إعادة تحميل {{name}}",
enable: "تم تفعيل {{name}}",
disable: "تم تعطيل {{name}}",
remove: "تمت إزالة {{name}}",
kept: "تم الإبقاء على {{name}}",
globalEnabled: "تم تفعيل الإضافات",
globalDisabled: "تم تعطيل الإضافات",
},
removeConfirmTitle: "إزالة {{id}}؟",
removeConfirmTitle: "إزالة {{name}}؟",
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
states: {
offlineTitle: "مضيف الإضافات غير متصل",
@@ -88,7 +89,8 @@ export const pluginSettings = {
installing: "Instalando…",
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
actions: {
menu: "Acciones para {{id}}",
menu: "Acciones para {{name}}",
settings: "Configuración",
reload: "Recargar",
reloading: "Recargando…",
enable: "Activar",
@@ -99,16 +101,16 @@ export const pluginSettings = {
removing: "Eliminando…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recargado",
enable: "{{id}} activado",
disable: "{{id}} desactivado",
remove: "{{id}} eliminado",
kept: "Se conservó {{id}}",
installed: "{{name}} instalado",
reload: "{{name}} recargado",
enable: "{{name}} activado",
disable: "{{name}} desactivado",
remove: "{{name}} eliminado",
kept: "Se conservó {{name}}",
globalEnabled: "Plugins activados",
globalDisabled: "Plugins desactivados",
},
removeConfirmTitle: "¿Eliminar {{id}}?",
removeConfirmTitle: "¿Eliminar {{name}}?",
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
states: {
offlineTitle: "El host de plugins está sin conexión",
@@ -143,7 +145,8 @@ export const pluginSettings = {
installing: "Installation…",
status: { running: "actif", disabled: "désactivé", failed: "échec" },
actions: {
menu: "Actions pour {{id}}",
menu: "Actions pour {{name}}",
settings: "Paramètres",
reload: "Recharger",
reloading: "Rechargement…",
enable: "Activer",
@@ -154,16 +157,16 @@ export const pluginSettings = {
removing: "Suppression…",
},
feedback: {
installed: "{{id}} installé",
reload: "{{id}} rechargé",
enable: "{{id}} activé",
disable: "{{id}} désactivé",
remove: "{{id}} supprimé",
kept: "{{id}} conservé",
installed: "{{name}} installé",
reload: "{{name}} rechargé",
enable: "{{name}} activé",
disable: "{{name}} désactivé",
remove: "{{name}} supprimé",
kept: "{{name}} conservé",
globalEnabled: "Plugins activés",
globalDisabled: "Plugins désactivés",
},
removeConfirmTitle: "Supprimer {{id}} ?",
removeConfirmTitle: "Supprimer {{name}} ?",
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
states: {
offlineTitle: "L’hôte des plugins est hors ligne",
@@ -198,7 +201,8 @@ export const pluginSettings = {
installing: "インストール中…",
status: { running: "実行中", disabled: "無効", failed: "失敗" },
actions: {
menu: "{{id}} のアクション",
menu: "{{name}} のアクション",
settings: "設定",
reload: "再読み込み",
reloading: "再読み込み中…",
enable: "有効化",
@@ -209,16 +213,16 @@ export const pluginSettings = {
removing: "削除中…",
},
feedback: {
installed: "{{id}} をインストールしました",
reload: "{{id}} を再読み込みしました",
enable: "{{id}} を有効にしました",
disable: "{{id}} を無効にしました",
remove: "{{id}} を削除しました",
kept: "{{id}} を保持しました",
installed: "{{name}} をインストールしました",
reload: "{{name}} を再読み込みしました",
enable: "{{name}} を有効にしました",
disable: "{{name}} を無効にしました",
remove: "{{name}} を削除しました",
kept: "{{name}} を保持しました",
globalEnabled: "プラグインを有効にしました",
globalDisabled: "プラグインを無効にしました",
},
removeConfirmTitle: "{{id}} を削除しますか?",
removeConfirmTitle: "{{name}} を削除しますか?",
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
states: {
offlineTitle: "プラグインホストはオフラインです",
@@ -252,7 +256,8 @@ export const pluginSettings = {
installing: "설치 중…",
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
actions: {
menu: "{{id}} 작업",
menu: "{{name}} 작업",
settings: "설정",
reload: "다시 로드",
reloading: "다시 로드 중…",
enable: "활성화",
@@ -263,16 +268,16 @@ export const pluginSettings = {
removing: "제거 중…",
},
feedback: {
installed: "{{id}} 설치됨",
reload: "{{id}} 다시 로드됨",
enable: "{{id}} 활성화됨",
disable: "{{id}} 비활성화됨",
remove: "{{id}} 제거됨",
kept: "{{id}} 유지됨",
installed: "{{name}} 설치됨",
reload: "{{name}} 다시 로드됨",
enable: "{{name}} 활성화됨",
disable: "{{name}} 비활성화됨",
remove: "{{name}} 제거됨",
kept: "{{name}} 유지됨",
globalEnabled: "플러그인 활성화됨",
globalDisabled: "플러그인 비활성화됨",
},
removeConfirmTitle: "{{id}}을(를) 제거할까요?",
removeConfirmTitle: "{{name}}을(를) 제거할까요?",
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
states: {
offlineTitle: "플러그인 호스트가 오프라인입니다",
@@ -307,7 +312,8 @@ export const pluginSettings = {
installing: "Instalando…",
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
actions: {
menu: "Ações para {{id}}",
menu: "Ações para {{name}}",
settings: "Configurações",
reload: "Recarregar",
reloading: "Recarregando…",
enable: "Ativar",
@@ -318,16 +324,16 @@ export const pluginSettings = {
removing: "Removendo…",
},
feedback: {
installed: "{{id}} instalado",
reload: "{{id}} recarregado",
enable: "{{id}} ativado",
disable: "{{id}} desativado",
remove: "{{id}} removido",
kept: "{{id}} mantido",
installed: "{{name}} instalado",
reload: "{{name}} recarregado",
enable: "{{name}} ativado",
disable: "{{name}} desativado",
remove: "{{name}} removido",
kept: "{{name}} mantido",
globalEnabled: "Plugins ativados",
globalDisabled: "Plugins desativados",
},
removeConfirmTitle: "Remover {{id}}?",
removeConfirmTitle: "Remover {{name}}?",
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
states: {
offlineTitle: "O host de plugins está offline",
@@ -362,7 +368,8 @@ export const pluginSettings = {
installing: "Установка…",
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
actions: {
menu: "Действия для {{id}}",
menu: "Действия для {{name}}",
settings: "Настройки",
reload: "Перезагрузить",
reloading: "Перезагрузка…",
enable: "Включить",
@@ -373,16 +380,16 @@ export const pluginSettings = {
removing: "Удаление…",
},
feedback: {
installed: "{{id}} установлен",
reload: "{{id}} перезагружен",
enable: "{{id}} включён",
disable: "{{id}} отключён",
remove: "{{id}} удалён",
kept: "{{id}} сохранён",
installed: "{{name}} установлен",
reload: "{{name}} перезагружен",
enable: "{{name}} включён",
disable: "{{name}} отключён",
remove: "{{name}} удалён",
kept: "{{name}} сохранён",
globalEnabled: "Плагины включены",
globalDisabled: "Плагины отключены",
},
removeConfirmTitle: "Удалить {{id}}?",
removeConfirmTitle: "Удалить {{name}}?",
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
states: {
offlineTitle: "Хост плагинов не в сети",
@@ -416,7 +423,8 @@ export const pluginSettings = {
installing: "正在安装…",
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
actions: {
menu: "{{id}} 的操作",
menu: "{{name}} 的操作",
settings: "设置",
reload: "重新加载",
reloading: "正在重新加载…",
enable: "启用",
@@ -427,16 +435,16 @@ export const pluginSettings = {
removing: "正在移除…",
},
feedback: {
installed: "已安装 {{id}}",
reload: "已重新加载 {{id}}",
enable: "已启用 {{id}}",
disable: "已禁用 {{id}}",
remove: "已移除 {{id}}",
kept: "已保留 {{id}}",
installed: "已安装 {{name}}",
reload: "已重新加载 {{name}}",
enable: "已启用 {{name}}",
disable: "已禁用 {{name}}",
remove: "已移除 {{name}}",
kept: "已保留 {{name}}",
globalEnabled: "插件已启用",
globalDisabled: "插件已禁用",
},
removeConfirmTitle: "移除 {{id}}?",
removeConfirmTitle: "移除 {{name}}?",
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
states: {
offlineTitle: "插件主机离线",
+5 -23
View File
@@ -34,7 +34,6 @@ import {
useComposerFullscreen,
type ComposerEditingSession,
} from "@/composer/editing-session";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import {
resolveComposerTrackControlClearance,
resolveComposerTrackTailClearance,
@@ -66,7 +65,6 @@ import { useAgentFactsLine } from "@/panels/agent-facts-line";
import { PaneSubtitle } from "@/panels/pane-subtitle";
import { definePanel } from "@/panels/panel-registry";
import { RenderProfile } from "@/utils/render-profiler";
import { useHasPluginComposerPills } from "@/plugins";
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
import {
type HostRuntimeConnectionStatus,
@@ -81,7 +79,7 @@ import {
deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
import { AgentTracks } from "@/panels/agent-tracks";
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
@@ -1110,18 +1108,10 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
parentAgentId: agentId,
rows: subagentRows,
});
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
const parentAgentId = useParentAgentId(serverId, agentId);
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
const parentPillAgentId = showsComposer ? parentAgentId : null;
const hasVisibleAgentTracks = hasAgentTracks({
parentAgentId: parentPillAgentId,
subagentRows,
tasks,
archiveFinishedStatus: archiveFinishedSubagents.status,
hasPluginComposerPills,
});
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
@@ -1199,13 +1189,11 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
workspaceId={workspaceId}
agentId={agentId}
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
hasActiveComposer={hasActiveComposer}
hasVisibleAgentTracks={hasVisibleAgentTracks}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
@@ -1220,7 +1208,6 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
tasks={tasks}
archiveFinishedStatus={archiveFinishedSubagents.status}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
hasPluginComposerPills={hasPluginComposerPills}
/>
) : null}
</View>
@@ -1366,36 +1353,31 @@ function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: str
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
serverId,
workspaceId,
agentId,
agent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
hasActiveComposer,
hasVisibleAgentTracks,
toast,
onOpenWorkspaceFile,
}: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
serverId: string;
workspaceId: string;
agentId?: string;
agent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
hasActiveComposer: boolean;
hasVisibleAgentTracks: boolean;
toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const isCompactFormFactor = useIsCompactFormFactor();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const hasVisibleComposerTracks =
hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat);
const bottomOverlayTailClearance = hasVisibleComposerTracks
// The track bar's space is reserved whenever the bar is mounted, pills or not: pills come and go
// every turn, and the transcript must not jump with them.
const bottomOverlayTailClearance = hasActiveComposer
? resolveComposerTrackTailClearance(isCompactFormFactor)
: 0;
const bottomOverlayControlClearance = hasVisibleComposerTracks
const bottomOverlayControlClearance = hasActiveComposer
? resolveComposerTrackControlClearance(isCompactFormFactor)
: 0;
const streamItemsRaw = useAgentStreamTail(serverId, agentId);
+1 -40
View File
@@ -1,7 +1,6 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { memo, useCallback, type ReactElement } from "react";
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
import { AgentTaskList } from "@/composer/task-list";
import { ComposerTrackBar } from "@/composer/tracks";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -40,7 +39,6 @@ export const AgentTracks = memo(function AgentTracks({
tasks,
archiveFinishedStatus,
onArchiveFinished,
hasPluginComposerPills,
}: {
serverId: string;
workspaceId: string;
@@ -50,10 +48,8 @@ export const AgentTracks = memo(function AgentTracks({
tasks: TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
onArchiveFinished: () => void;
hasPluginComposerPills: boolean;
}): ReactElement | null {
}): ReactElement {
const { tabId } = usePaneContext();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const openInSidePane = useSettings((settings) => settings.openInSidePane);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
@@ -112,19 +108,6 @@ export const AgentTracks = memo(function AgentTracks({
});
}, [isCompact, openInSidePane, workspaceKey]);
if (
!hasWorkspaceDiffStat &&
!hasAgentTracks({
parentAgentId,
subagentRows,
tasks,
archiveFinishedStatus,
hasPluginComposerPills,
})
) {
return null;
}
return (
<ComposerTrackBar>
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
@@ -154,25 +137,3 @@ export const AgentTracks = memo(function AgentTracks({
</ComposerTrackBar>
);
});
export function hasAgentTracks({
parentAgentId,
subagentRows,
tasks,
archiveFinishedStatus,
hasPluginComposerPills = false,
}: {
parentAgentId: string | null;
subagentRows: readonly SubagentRow[];
tasks: readonly TodoEntry[] | undefined;
archiveFinishedStatus: ArchiveFinishedStatus;
hasPluginComposerPills?: boolean;
}): boolean {
return (
parentAgentId !== null ||
subagentRows.length > 0 ||
Boolean(tasks?.length) ||
archiveFinishedStatus.kind !== "idle" ||
hasPluginComposerPills
);
}
+9 -13
View File
@@ -13,7 +13,7 @@ import {
} from "react";
import { Pressable, Text, View, useWindowDimensions } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native";
import { AlertCircle, ChevronDown, ChevronUp, MoreHorizontal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import {
MenuRoot,
@@ -247,7 +247,11 @@ function ButtonControl({ view }: { view: ButtonView }) {
const composer = entry.placement === "composer";
const disabled = button.disabled || entry.pending;
const expanded = button.behavior.kind !== "action";
const chevron = !composer && !props.layout.compact && expanded;
// A chevron marks a button that opens something. Composer pills keep their label on compact and
// open their surface above, so theirs points up and shows at every width; compact headers drop
// the label and the chevron with it.
const chevron = expanded && (composer || !props.layout.compact);
const Chevron = composer ? ChevronUp : ChevronDown;
let label = button.label;
if (composer) label = button.label ?? button.title;
else if (props.layout.compact) label = undefined;
@@ -284,7 +288,7 @@ function ButtonControl({ view }: { view: ButtonView }) {
) : null}
{chevron ? (
<View testID="plugin-button-chevron">
<ChevronDown size={12} color={props.theme.colors.foregroundMuted} />
<Chevron size={12} color={props.theme.colors.foregroundMuted} />
</View>
) : null}
</>
@@ -559,14 +563,6 @@ function useButtons(serverId: string, workspaceId: string, agentId: string | nul
);
}
export function useHasPluginComposerPills(
serverId: string,
workspaceId: string,
agentId: string,
): boolean {
return useButtons(serverId, workspaceId, agentId).length > 0;
}
export function PluginComposerPills({
serverId,
workspaceId,
@@ -660,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",
+1 -6
View File
@@ -3,9 +3,4 @@ export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
export { PluginSurfaceScreen } from "./surface-screen";
export { usePluginAttachmentPicker } from "./attachments/picker";
export { PluginResourceAttachmentPill } from "./attachments/pill";
export {
PluginComposerPills,
PluginHeaderButtons,
useHasPluginComposerPills,
usePluginWorkspaceMenu,
} from "./buttons/view";
export { PluginComposerPills, PluginHeaderButtons, usePluginWorkspaceMenu } from "./buttons/view";
+36
View File
@@ -0,0 +1,36 @@
import type { PluginListItem } from "@getpaseo/protocol/messages";
import { useFetchQuery } from "@/data/query";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
/** A host's configured plugins, as listed on Settings → Plugins. One cache entry per host. */
export function usePluginList(serverId: string, options: { enabled?: boolean } = {}) {
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginManagement");
return useFetchQuery({
queryKey: ["plugins", serverId] as const,
queryFn: async () => {
if (!client) throw new Error("Plugin host is offline");
return client.listPlugins();
},
enabled: (options.enabled ?? true) && Boolean(client && connected && supported),
dataShape: "list",
staleTimeMs: 1_000,
});
}
/** The manifest's display name, or the plugin id when it has none. */
export function pluginDisplayName(plugin: Pick<PluginListItem, "id" | "name">): string {
return plugin.name ?? plugin.id;
}
/** The display name of one configured plugin; its id until the list loads. */
export function usePluginDisplayName(
target: { serverId: string; pluginId: string } | null,
): string | undefined {
const plugins = usePluginList(target?.serverId ?? "", { enabled: target !== null });
if (!target) return undefined;
const plugin = plugins.data?.find((candidate) => candidate.id === target.pluginId);
return plugin ? pluginDisplayName(plugin) : target.pluginId;
}
+92 -48
View File
@@ -1,17 +1,20 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { Platform, ScrollView, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { ArrowLeft, Settings } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { PluginIconView } from "../icons";
import { useInstalledPlugin } from "../registry";
import type { InstalledPlugin } from "../types";
import { PluginInstallationProvider } from "../installation-provider";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { toPluginTheme } from "../theme";
@@ -23,45 +26,84 @@ interface SettingsIdentity {
screenId: string;
}
function PluginSettingsMenuItem({
serverId,
pluginId,
screenId,
title,
disabled,
}: SettingsIdentity & { title: string; disabled?: boolean }) {
const open = useCallback(
() => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)),
[serverId, pluginId, screenId],
);
const ThemedSettingsIcon = withUnistyles(Settings);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** Opens the plugin's settings at its first screen. Renders nothing when it has none. */
export function PluginSettingsButton({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) {
const { t } = useTranslation();
const plugin = useInstalledPlugin(serverId, pluginId);
const supported = useHostFeature(serverId, "pluginSettings");
const firstScreenId = plugin?.settingsScreens[0]?.id;
const open = useCallback(() => {
if (firstScreenId) router.push(buildPluginSettingsRoute(serverId, pluginId, firstScreenId));
}, [serverId, pluginId, firstScreenId]);
const compact = useIsCompactFormFactor();
if (!supported || !firstScreenId) return null;
return (
<DropdownMenuItem onSelect={open} disabled={disabled}>
{title}
</DropdownMenuItem>
<ToolbarButton
label={t("settings.plugins.actions.settings")}
compact={compact}
onPress={open}
testID={`plugin-settings-${pluginId}`}
>
<ThemedSettingsIcon size={paneContentToolbarIconSize(compact)} uniProps={mutedIconMapping} />
</ToolbarButton>
);
}
export function PluginSettingsMenuItems({
serverId,
pluginId,
disabled,
}: Omit<SettingsIdentity, "screenId"> & { disabled?: boolean }) {
const plugin = useInstalledPlugin(serverId, pluginId);
const supported = useHostFeature(serverId, "pluginSettings");
if (!supported || !plugin) return null;
function renderNoIcon() {
return null;
}
/** A plugin-supplied icon component may throw; its tab then shows the label alone. */
function SettingsTabIcon({
plugin,
icon,
size,
color,
}: {
plugin: InstalledPlugin;
icon: InstalledPlugin["settingsScreens"][number]["icon"];
size: number;
color: string;
}) {
return (
<>
{plugin.settingsScreens.map((screen) => (
<PluginSettingsMenuItem
key={screen.id}
serverId={serverId}
pluginId={pluginId}
screenId={screen.id}
title={plugin.i18n.resolve(screen.title, plugin.locale)}
disabled={disabled}
/>
))}
</>
<SurfaceErrorBoundary installation={plugin} Surface={icon} renderError={renderNoIcon}>
<PluginIconView icon={icon} size={size} color={color} />
</SurfaceErrorBoundary>
);
}
/**
* One tab per registered settings screen. A plugin with a single screen shows no tabs.
* Render inside the installation's provider: tab icons may be plugin components.
*/
function SettingsTabs({ plugin, screenId }: { plugin: InstalledPlugin; screenId: string }) {
const options = useMemo<SegmentedControlOption<string>[]>(
() =>
plugin.settingsScreens.map((screen) => ({
value: screen.id,
label: plugin.i18n.resolve(screen.title, plugin.locale),
icon: ({ color, size }) => (
<SettingsTabIcon plugin={plugin} icon={screen.icon} size={size} color={color} />
),
testID: `plugin-settings-tab-${screen.id}`,
})),
[plugin],
);
// Tabs swap the screen in place, so Back still returns to the Plugins page.
const select = useCallback((id: string) => router.setParams({ screenId: id }), []);
if (options.length < 2) return null;
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabs}
testID="plugin-settings-tabs"
>
<SegmentedControl options={options} value={screenId} onValueChange={select} />
</ScrollView>
);
}
@@ -112,18 +154,19 @@ function SettingsContent({
return <Text style={styles.message}>{t("settings.plugins.screens.unavailable")}</Text>;
const Component = screen.Component;
return (
<View>
<SurfaceErrorBoundary
installation={plugin}
Surface={Component}
resetKey={attempt}
renderError={renderError}
>
<PluginInstallationProvider plugin={plugin}>
<PluginInstallationProvider plugin={plugin}>
<View>
<SettingsTabs plugin={plugin} screenId={screen.id} />
<SurfaceErrorBoundary
installation={plugin}
Surface={Component}
resetKey={attempt}
renderError={renderError}
>
<Component theme={theme} layout={layout} host={host} />
</PluginInstallationProvider>
</SurfaceErrorBoundary>
</View>
</SurfaceErrorBoundary>
</View>
</PluginInstallationProvider>
);
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
@@ -153,5 +196,6 @@ export function PluginSettingsContent({
}
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
tabs: { flexGrow: 0, marginBottom: theme.spacing[4] },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
@@ -513,6 +513,23 @@ describe("ReplicaCache", () => {
expect(storage.changes[0]?.deletes).toEqual([]);
});
it("keeps a background agent background across cache reopen", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const helper = { ...agent("helper"), background: true };
const visible = agent("visible");
writer.commitDirectoryMutations(SERVER_ID, [
{ kind: "agent", type: "upsert", id: helper.id, value: helper },
{ kind: "agent", type: "upsert", id: visible.id, value: visible },
]);
await writer.flush();
const restored = await createCache(storage).readDirectory(SERVER_ID);
expect(restored.agents.get("helper")?.background).toBe(true);
expect(restored.agents.get("visible")?.background).toBe(false);
});
it("round-trips identified and anonymous open turns without changing protocol status", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
+29 -20
View File
@@ -247,6 +247,8 @@ const StoredAgentSnapshotSchema = z.strictObject({
lastError: z.string().optional(),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()),
// Optional: clients without background_agents never received a background agent to cache.
background: z.boolean().optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: IsoDateSchema.nullable().optional(),
@@ -612,6 +614,31 @@ function serializeAgentTurn(agent: Agent): NonNullable<StoredAgent["turn"]> {
};
}
function serializeAgentCapabilities(
capabilities: Agent["capabilities"],
): StoredAgent["snapshot"]["capabilities"] {
return {
supportsStreaming: capabilities.supportsStreaming,
supportsSessionPersistence: capabilities.supportsSessionPersistence,
...(capabilities.supportsSessionListing !== undefined
? { supportsSessionListing: capabilities.supportsSessionListing }
: {}),
supportsDynamicModes: capabilities.supportsDynamicModes,
supportsMcpServers: capabilities.supportsMcpServers,
supportsReasoningStream: capabilities.supportsReasoningStream,
supportsToolInvocations: capabilities.supportsToolInvocations,
...(capabilities.supportsRewindConversation !== undefined
? { supportsRewindConversation: capabilities.supportsRewindConversation }
: {}),
...(capabilities.supportsRewindFiles !== undefined
? { supportsRewindFiles: capabilities.supportsRewindFiles }
: {}),
...(capabilities.supportsRewindBoth !== undefined
? { supportsRewindBoth: capabilities.supportsRewindBoth }
: {}),
};
}
function serializeAgent(agent: Agent): StoredAgent {
const snapshot = {
id: agent.id,
@@ -632,26 +659,7 @@ function serializeAgent(agent: Agent): StoredAgent {
},
}
: {}),
capabilities: {
supportsStreaming: agent.capabilities.supportsStreaming,
supportsSessionPersistence: agent.capabilities.supportsSessionPersistence,
...(agent.capabilities.supportsSessionListing !== undefined
? { supportsSessionListing: agent.capabilities.supportsSessionListing }
: {}),
supportsDynamicModes: agent.capabilities.supportsDynamicModes,
supportsMcpServers: agent.capabilities.supportsMcpServers,
supportsReasoningStream: agent.capabilities.supportsReasoningStream,
supportsToolInvocations: agent.capabilities.supportsToolInvocations,
...(agent.capabilities.supportsRewindConversation !== undefined
? { supportsRewindConversation: agent.capabilities.supportsRewindConversation }
: {}),
...(agent.capabilities.supportsRewindFiles !== undefined
? { supportsRewindFiles: agent.capabilities.supportsRewindFiles }
: {}),
...(agent.capabilities.supportsRewindBoth !== undefined
? { supportsRewindBoth: agent.capabilities.supportsRewindBoth }
: {}),
},
capabilities: serializeAgentCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
availableModes: [],
pendingPermissions: [],
@@ -659,6 +667,7 @@ function serializeAgent(agent: Agent): StoredAgent {
...(agent.lastError ? { lastError: agent.lastError } : {}),
title: agent.title,
labels: agent.labels,
...(agent.background ? { background: true } : {}),
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
+7 -12
View File
@@ -114,7 +114,7 @@ import {
HostTerminalsPage,
} from "@/screens/settings/host-page";
import { PluginSettingsContent } from "@/plugins/settings";
import { useInstalledPlugins } from "@/plugins/registry";
import { usePluginDisplayName } from "@/plugins/plugin-list";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
@@ -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,
@@ -1267,15 +1267,10 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
returnFromSettings({ kind: "root" });
}, []);
const installedPlugins = useInstalledPlugins();
// The header names the plugin; its tabs name the open settings screen.
const pluginDisplayName = usePluginDisplayName(view.kind === "plugin" ? view : null);
const detailTitle = ((): string | undefined => {
if (view.kind === "plugin") {
const plugin = installedPlugins.find(
(candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId,
);
const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId);
return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`;
}
if (view.kind === "plugin") return pluginDisplayName;
if (view.kind === "host") {
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
return item ? t(item.labelKey) : undefined;
@@ -1528,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 },
@@ -1554,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 },
@@ -1,5 +1,5 @@
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
import { PluginSettingsMenuItems } from "@/plugins/settings";
import { PluginSettingsButton } from "@/plugins/settings";
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -29,10 +29,10 @@ import { SettingsSection } from "@/components/settings/headings/settings-section
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model";
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const;
const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources";
type PluginRowAction = "reload" | "enable" | "disable" | "remove";
@@ -49,7 +49,6 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine
function PluginActionsMenu({
plugin,
serverId,
pending,
pendingAction,
onAction,
@@ -57,19 +56,18 @@ function PluginActionsMenu({
supportsLogs,
}: {
plugin: PluginListItem;
serverId: string;
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
onOpenLogs(pluginId: string): void;
onOpenLogs(plugin: PluginListItem): void;
supportsLogs: boolean;
}) {
const { t } = useTranslation();
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
const openLogs = useCallback(() => onOpenLogs(plugin), [onOpenLogs, plugin]);
const removeIcon = useMemo(() => <Trash2 size={16} color={styles.dangerIcon.color} />, []);
const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id });
const menuLabel = t("settings.plugins.actions.menu", { name: pluginDisplayName(plugin) });
return (
<DropdownMenu compactMode="sheet">
@@ -83,7 +81,6 @@ function PluginActionsMenu({
<MoreHorizontal size={18} color={styles.menuIcon.color} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} sheetTitle={menuLabel}>
<PluginSettingsMenuItems serverId={serverId} pluginId={plugin.id} disabled={pending} />
{supportsLogs ? (
<DropdownMenuItem onSelect={openLogs} disabled={pending}>
{t("settings.plugins.logs.action")}
@@ -128,7 +125,7 @@ function PluginRow({
pending: boolean;
pendingAction?: PluginRowAction;
onAction(action: PluginRowAction, plugin: PluginListItem): void;
onOpenLogs(pluginId: string): void;
onOpenLogs(plugin: PluginListItem): void;
supportsLogs: boolean;
}) {
const { t } = useTranslation();
@@ -145,10 +142,14 @@ function PluginRow({
() => <StatusBadge label={statusLabel} variant={badgeVariant} />,
[badgeVariant, statusLabel],
);
const name = pluginDisplayName(plugin);
// A display name hides the id that the CLI and plugin routes use, so show it beneath.
const shownId = name === plugin.id ? null : plugin.id;
const hint = useMemo(
() =>
plugin.description || plugin.installation ? (
shownId || plugin.description || plugin.installation ? (
<View>
{shownId ? <Text style={styles.pluginSource}>{shownId}</Text> : null}
{plugin.description ? (
<Text style={styles.pluginDescription}>{plugin.description}</Text>
) : null}
@@ -157,20 +158,21 @@ function PluginRow({
) : null}
</View>
) : undefined,
[plugin.description, plugin.installation],
[shownId, plugin.description, plugin.installation],
);
const toggleLabel = `${plugin.id}: ${t(
const toggleLabel = `${name}: ${t(
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
)}`;
return (
<SettingsRow
label={plugin.id}
label={name}
labelAccessory={statusBadge}
hint={hint}
error={clientError ?? plugin.error}
testID={`plugin-row-${plugin.id}`}
>
<View style={styles.pluginControls} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
<View style={styles.pluginControls} accessibilityLabel={`${name} ${statusLabel}`}>
<PluginSettingsButton serverId={serverId} pluginId={plugin.id} />
<Switch
value={plugin.enabled}
onValueChange={toggle}
@@ -179,7 +181,6 @@ function PluginRow({
/>
<PluginActionsMenu
plugin={plugin}
serverId={serverId}
pending={pending}
pendingAction={pendingAction}
onAction={onAction}
@@ -193,15 +194,16 @@ function PluginRow({
function PluginLogsSheet({
client,
pluginId,
plugin,
serverId,
onClose,
}: {
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
pluginId: string;
plugin: PluginListItem;
serverId: string;
onClose(): void;
}) {
const pluginId = plugin.id;
const { t } = useTranslation();
const logs = useFetchQuery({
queryKey: ["plugin-logs", serverId, pluginId],
@@ -214,7 +216,7 @@ function PluginLogsSheet({
}, [logs]);
const header = useMemo<SheetHeader>(
() => ({
title: t("settings.plugins.logs.title", { id: pluginId }),
title: t("settings.plugins.logs.title", { name: pluginDisplayName(plugin) }),
actions: (
<Button variant="outline" onPress={refresh} disabled={logs.isFetching}>
{logs.isFetching
@@ -223,7 +225,7 @@ function PluginLogsSheet({
</Button>
),
}),
[logs.isFetching, pluginId, refresh, t],
[logs.isFetching, plugin, refresh, t],
);
let content = <Text style={styles.logsState}>{t("settings.plugins.logs.loading")}</Text>;
@@ -276,7 +278,6 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const { config, patchConfig } = useDaemonConfig(serverId);
const refreshQueue = useRef(Promise.resolve());
useInstalledPlugins();
const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]);
const [installForm] = useState(openPluginInstallForm);
const installState = useSyncExternalStore(
installForm.subscribe,
@@ -293,20 +294,11 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
),
[t],
);
const [logsPluginId, setLogsPluginId] = useState<string | null>(null);
const [logsPlugin, setLogsPlugin] = useState<PluginListItem | null>(null);
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
null,
);
const plugins = useFetchQuery({
queryKey,
queryFn: async () => {
if (!client) throw new Error("Plugin host is offline");
return client.listPlugins();
},
enabled: Boolean(client && connected && supported),
dataShape: "list",
staleTimeMs: 1_000,
});
const plugins = usePluginList(serverId);
const refetchPlugins = plugins.refetch;
const refresh = useCallback(async () => {
if (!client) return;
@@ -356,27 +348,28 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
run: async () => {
const installed = await client.installPluginSource(input);
installForm.reset();
return t("settings.plugins.feedback.installed", { id: installed.id });
return t("settings.plugins.feedback.installed", { name: pluginDisplayName(installed) });
},
});
}, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]);
const action = useCallback(
(name: PluginRowAction, plugin: PluginListItem) => {
if (!client) return;
const pluginName = pluginDisplayName(plugin);
const run = async () => {
if (name === "remove") {
const confirmed = await confirmDialog({
title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }),
title: t("settings.plugins.removeConfirmTitle", { name: pluginName }),
message: t("settings.plugins.removeConfirmMessage"),
confirmLabel: t("settings.plugins.actions.remove"),
destructive: true,
});
if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id });
if (!confirmed) return t("settings.plugins.feedback.kept", { name: pluginName });
await client.removePlugin(plugin.id);
} else {
await client[`${name}Plugin`](plugin.id);
}
return t(`settings.plugins.feedback.${name}`, { id: plugin.id });
return t(`settings.plugins.feedback.${name}`, { name: pluginName });
};
setFeedback(null);
mutation.mutate({ action: name, pluginId: plugin.id, run });
@@ -408,7 +401,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
const retry = useCallback(() => {
void refetchPlugins();
}, [refetchPlugins]);
const closeLogs = useCallback(() => setLogsPluginId(null), []);
const closeLogs = useCallback(() => setLogsPlugin(null), []);
if (pageState === "offline") {
return (
@@ -454,7 +447,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
pending={mutation.isPending}
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
onAction={action}
onOpenLogs={setLogsPluginId}
onOpenLogs={setLogsPlugin}
supportsLogs={logsSupported}
/>
);
@@ -528,10 +521,10 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
) : null}
{catalogContent}
</SettingsSection>
{client && logsPluginId ? (
{client && logsPlugin ? (
<PluginLogsSheet
client={client}
pluginId={logsPluginId}
plugin={logsPlugin}
serverId={serverId}
onClose={closeLogs}
/>
@@ -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(
() => (
<WorkspaceHeaderProjectRow
@@ -45,9 +59,10 @@ export function CompactWorkspaceHeader({
),
[subtitle, isSubtitleDistinct, serverId],
);
const actions = (
<>
{children}
const symbols = {
"tab-count": tabSwitcher,
overflow: children,
explorer: (
<WorkspaceExplorerToggle
onPress={onToggleExplorer}
label={explorerToggleLabel}
@@ -56,17 +71,25 @@ export function CompactWorkspaceHeader({
accessibilityState={explorerToggleAccessibilityState}
mobile
/>
</>
);
),
};
const actions = resolveWorkspaceBarActions(headerVariant).map((symbol) => (
<Fragment key={symbol}>{symbols[symbol]}</Fragment>
));
return (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actions}
/>
<IconButtonChromeProvider value="header-island">
{headerVariant === "compact" ? (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actionsInBar ? undefined : actions}
/>
) : null}
<WindowLayoutActions active={actionsActive}>{actions}</WindowLayoutActions>
</IconButtonChromeProvider>
);
}
@@ -8,3 +8,5 @@ export {
type UseWorkspaceScriptsMenuPagesInput,
type WorkspaceScriptsMenuPages,
} from "./workspace-scripts-menu-pages";
export { useWorkspaceScriptActions } from "./use-workspace-script-actions";
@@ -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<DaemonClient, "startWorkspaceScript" | "killTerminal"> | 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,
@@ -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 (
<WorkspaceScriptsButton
actions={actions}
scripts={scripts}
liveTerminalIds={LIVE_TERMINAL_IDS}
hideLabels={hideLabels}
/>
);
}
function renderScripts(
scripts: WorkspaceScriptPayload[],
options: RenderScriptsOptions = {},
@@ -257,13 +279,9 @@ function renderScripts(
function element(nextScripts: WorkspaceScriptPayload[]): ReactElement {
return (
<QueryClientProvider client={queryClient}>
<WorkspaceScriptsButton
serverId="test-server"
workspaceId="workspace-1"
scripts={nextScripts}
liveTerminalIds={LIVE_TERMINAL_IDS}
hideLabels={options.hideLabels}
/>
<ToastApiProvider api={testToast}>
<ScriptsOwner scripts={nextScripts} hideLabels={options.hideLabels} />
</ToastApiProvider>
</QueryClientProvider>
);
}
@@ -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,
@@ -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(
@@ -82,6 +82,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 +116,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 +252,7 @@ function WorkspaceNewTabButton({
testID="workspace-new-tab-button"
style={placement === "inline" ? styles.inlineNewTabButton : undefined}
>
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
<ThemedPlus size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
</ToolbarButton>
<WorkspaceNewTabMenuContent
serverId={serverId}
@@ -298,11 +301,11 @@ function WorkspacePaneToolbarActions({
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
const splitActionsVisible = showSplitActions && Boolean(onSplitRight && onSplitDown);
const splitRightLeading = useMemo(
() => <ThemedColumns2 size={14} uniProps={extraMutedColorMapping} />,
() => <ThemedColumns2 size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />,
[],
);
const splitDownLeading = useMemo(
() => <ThemedRows2 size={14} uniProps={extraMutedColorMapping} />,
() => <ThemedRows2 size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />,
[],
);
const splitRightTrailing = useMemo(
@@ -341,9 +344,9 @@ function WorkspacePaneToolbarActions({
onPress={onTogglePaneMaximized}
>
{paneMaximized ? (
<ThemedMinimize size={14} uniProps={extraMutedColorMapping} />
<ThemedMinimize size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
) : (
<ThemedMaximize size={14} uniProps={extraMutedColorMapping} />
<ThemedMaximize size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
)}
</ToolbarButton>
) : null}
@@ -354,7 +357,7 @@ function WorkspacePaneToolbarActions({
label={t("workspace.git.actions.moreActions")}
testID="workspace-split-pane-menu"
>
<ThemedEllipsis size={14} uniProps={extraMutedColorMapping} />
<ThemedEllipsis size={TAB_RAIL_GLYPH_SIZE} uniProps={extraMutedColorMapping} />
</ToolbarButton>
<DropdownMenuContent
side="bottom"
@@ -409,7 +412,7 @@ function WorkspaceExitFocusModeButton({
testID="workspace-exit-focus-mode"
onPress={onPress}
>
<ThemedX size={14} uniProps={mutedColorMapping} />
<ThemedX size={TAB_RAIL_GLYPH_SIZE} uniProps={mutedColorMapping} />
</ToolbarButton>
</View>
);
@@ -7,6 +7,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 +63,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";
@@ -502,6 +503,7 @@ function WorkspaceHeaderTitleBar({
onCopyBranchName,
onOpenSetupTab,
}: WorkspaceHeaderTitleBarProps) {
const actionsInBar = resolveWorkspaceActionPlacement(useWindowLayout()) === "vertical-bar";
return (
<View style={styles.headerTitleContainer}>
{isLoading ? (
@@ -518,18 +520,20 @@ function WorkspaceHeaderTitleBar({
/>
</View>
)}
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenuDesktop
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
/>
</View>
{actionsInBar ? null : (
<View style={styles.compactHeaderMenuCluster}>
<WorkspaceHeaderMenuDesktop
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
importAgentDisabled={importAgentDisabled}
copyPathDisabled={copyPathDisabled}
onOpenImportSheet={onOpenImportSheet}
onCopyWorkspacePath={onCopyWorkspacePath}
onCopyBranchName={onCopyBranchName}
onOpenSetupTab={onOpenSetupTab}
/>
</View>
)}
</View>
);
}
@@ -1022,8 +1026,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 +1220,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({
@@ -3189,11 +3203,9 @@ function WorkspaceScreenContent({
<PluginHeaderButtons serverId={normalizedServerId} workspaceId={normalizedWorkspaceId} />
{workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? (
<WorkspaceScriptsButton
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
actions={scriptActions}
scripts={workspaceDescriptor.scripts}
liveTerminalIds={liveTerminalIds}
onScriptTerminalStarted={handleScriptTerminalStarted}
onViewTerminal={handleViewScriptTerminal}
onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab}
hideLabels
@@ -3211,15 +3223,17 @@ function WorkspaceScreenContent({
{workspaceDirectory ? (
<>
<WorkspaceActions serverId={normalizedServerId} cwd={workspaceDirectory} />
<WorkspaceHeaderExplorerToggle
owner={explorerToggleOwner}
onPress={handleToggleExplorerSidebar}
label={explorerSidebarToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
style={styles.compactHeaderActionButton}
accessibilityState={explorerSidebarToggleAccessibilityState}
/>
{workspaceActionsInBar ? null : (
<WorkspaceHeaderExplorerToggle
owner={explorerToggleOwner}
onPress={handleToggleExplorerSidebar}
label={explorerSidebarToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
style={styles.compactHeaderActionButton}
accessibilityState={explorerSidebarToggleAccessibilityState}
/>
)}
</>
) : null}
</View>
@@ -3231,13 +3245,14 @@ function WorkspaceScreenContent({
workspaceDirectory,
activeFileLocation,
liveTerminalIds,
handleScriptTerminalStarted,
scriptActions,
handleViewScriptTerminal,
handleOpenUrlInBrowserTab,
handleToggleExplorerSidebar,
explorerSidebarToggleLabel,
explorerSidebarToggleAccessibilityState,
explorerToggleOwner,
workspaceActionsInBar,
t,
],
);
@@ -3343,104 +3358,113 @@ 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 ? (
<CompactWorkspaceHeader
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
serverId={normalizedServerId}
onToggleExplorer={handleToggleExplorerSidebar}
explorerToggleLabel={explorerSidebarToggleLabel}
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
>
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
createTerminalDisabled={createTerminalDisabled}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
<WorkspaceHeaderMenuMobile
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
scripts={compactHeaderScripts}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</CompactWorkspaceHeader>
) : null,
const workspaceTabSwitcher = useMemo(
() => (
<MobileWorkspaceTabSwitcher
tabs={tabs}
activeTabKey={activeTabKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
onSelectSwitcherTab={handleSelectSwitcherTab}
createTerminalDisabled={createTerminalDisabled}
onCreateDraftTab={handleCreateDraftTab}
onCreateTerminal={handleCreateTerminal}
onCopyResumeCommand={handleCopyResumeCommand}
onCopyAgentId={handleCopyAgentId}
onCopyTerminalId={handleCopyTerminalId}
onCopyFilePath={handleCopyFilePath}
onReloadAgent={handleReloadAgent}
onRenameTab={handleRenameTab}
onCloseTab={handleCloseTabById}
onCloseTabsAbove={handleCloseTabsToLeft}
onCloseTabsBelow={handleCloseTabsToRight}
onCloseOtherTabs={handleCloseOtherTabs}
/>
),
[
activeTabKey,
createTerminalDisabled,
handleCloseOtherTabs,
handleCloseTabById,
handleCloseTabsToLeft,
handleCloseTabsToRight,
handleCopyAgentId,
handleCopyFilePath,
handleCopyResumeCommand,
handleCopyTerminalId,
handleCreateDraftTab,
handleCreateTerminal,
handleReloadAgent,
handleRenameTab,
handleSelectSwitcherTab,
normalizedServerId,
normalizedWorkspaceId,
tabs,
],
);
const compactWorkspaceHeader = useMemo(
() => (
<CompactWorkspaceHeader
headerVariant={isMobile ? "compact" : "full"}
actionsActive={isRouteFocused && (!isMobile || workspacePanelActive)}
isLoading={isWorkspaceHeaderLoading}
title={workspaceHeaderTitle}
subtitle={workspaceHeaderSubtitle}
isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct}
serverId={normalizedServerId}
onToggleExplorer={handleToggleExplorerSidebar}
explorerToggleLabel={explorerSidebarToggleLabel}
explorerToggleAccessibilityState={explorerSidebarToggleAccessibilityState}
tabSwitcher={workspaceTabSwitcher}
>
<WorkspaceHeaderMenuMobile
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
scripts={compactHeaderScripts}
currentBranchName={currentBranchName}
showWorkspaceSetup={showWorkspaceSetup}
showCreateBrowserTab={showCreateBrowserTab}
createTerminalDisabled={createTerminalDisabled}
importAgentDisabled={!canOpenImportSheet}
copyPathDisabled={!workspaceDirectory}
onCreateTerminalWithProfile={handleCreateTerminalWithProfile}
onCreateBrowser={handleCreateBrowserTab}
onOpenImportSheet={openImportSheet}
onCopyWorkspacePath={handleCopyWorkspacePath}
onCopyBranchName={handleCopyBranchName}
onOpenSetupTab={handleOpenSetupTab}
/>
</CompactWorkspaceHeader>
),
[
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,10 +3472,11 @@ function WorkspaceScreenContent({
openImportSheet,
showCreateBrowserTab,
showWorkspaceSetup,
tabs,
workspaceDirectory,
workspaceHeaderSubtitle,
workspaceHeaderTitle,
workspacePanelActive,
workspaceTabSwitcher,
],
);
const desktopSplitContent = useMemo(() => {
@@ -3559,6 +3584,7 @@ function WorkspaceScreenContent({
</View>
) : (
<View style={styles.centerColumn}>
{compactWorkspaceHeader}
{rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()}
{shouldRenderDesktopPaneFallback ? (
+2
View File
@@ -97,6 +97,8 @@ export interface Agent {
attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null;
archivedAt?: Date | null;
/** Background agents stay out of tabs, the subagent track and agent lists. Open them by id. */
background?: boolean;
parentAgentId: string | null;
labels: Record<string, string>;
projectPlacement?: ProjectPlacementPayload | null;
+22 -19
View File
@@ -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<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(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<T>(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 {
+16
View File
@@ -246,6 +246,22 @@ describe("selectSubagentsForParent", () => {
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("excludes background children from the track", () => {
setAgents([
makeAgent({ id: "parent-a" }),
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
makeAgent({ id: "background-child", parentAgentId: "parent-a", background: true }),
]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{ serverId: SERVER_ID, parentAgentId: "parent-a" },
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("excludes siblings, unrelated agents, and grandchildren", () => {
setAgents([
makeAgent({ id: "parent-a" }),
+1
View File
@@ -82,6 +82,7 @@ export function selectSubagentsForParent(
const parent = getParentAddressFromLabels(agent.labels, serverId);
if (
agent.archivedAt ||
agent.background ||
pendingArchiveIds.has(`${serverId}:${agent.id}`) ||
parent?.serverId !== params.serverId ||
parent.agentId !== params.parentAgentId
@@ -85,6 +85,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
archivedAt: agent.archivedAt?.toISOString() ?? null,
...(agent.background ? { background: true } : {}),
};
}
@@ -133,6 +134,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp,
archivedAt,
background: snapshot.background === true,
parentAgentId,
labels: snapshot.labels,
};
@@ -14,6 +14,7 @@ function agent(input: {
pendingPermissionCount?: number;
archivedAt?: string | null;
parentAgentId?: string | null;
background?: boolean;
}): Agent {
return {
serverId: "host-a",
@@ -61,6 +62,7 @@ function agent(input: {
attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null,
archivedAt: input.archivedAt ? new Date(input.archivedAt) : null,
parentAgentId: input.parentAgentId ?? null,
background: input.background ?? false,
labels: {},
};
}
@@ -158,6 +160,33 @@ describe("workspace agent activity index", () => {
);
});
it("does not let a running background agent change root workspace activity", () => {
const index = buildWorkspaceAgentActivityIndex(
new Map([
[
"root",
agent({ id: "root", workspaceId: "workspace-a", updatedAt: "2026-06-01T10:00:00.000Z" }),
],
[
"classifier",
agent({
id: "classifier",
workspaceId: "workspace-a",
status: "running",
updatedAt: "2026-06-01T10:05:00.000Z",
background: true,
}),
],
]),
);
expect(index.get("workspace-a")).toEqual({
agentId: "root",
status: "done",
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
});
});
it("does not let archived or child agents change root workspace activity", () => {
const index = buildWorkspaceAgentActivityIndex(
new Map([
@@ -22,7 +22,12 @@ export function buildWorkspaceAgentActivityIndex(
for (const agent of agents.values()) {
const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined;
if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) {
if (
agent.archivedAt ||
agent.background ||
!agent.workspaceId ||
!isWorkspaceRootAgent(agent, parentAgent)
) {
continue;
}
@@ -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(<WindowLayoutDivisionGap layout={layout} sidebarVisible compact={false} />),
);
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(
<PortalProvider>
<WindowLayoutContext.Provider value={geometry}>
<WindowLayoutActionsHost />
<WindowLayoutActions active={active}>
<HeaderToggleButton
onPress={noop}
accessibilityRole="button"
accessibilityLabel="Explorer"
tooltipLabel="Explorer"
tooltipSide="left"
tooltipKeys={noKeys}
accessibilityState={explorerState}
>
<Text>Explorer</Text>
</HeaderToggleButton>
<DropdownMenu>
<DropdownMenuTrigger accessibilityRole="button" accessibilityLabel="Bar menu">
<Text>Menu</Text>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={noop}>Bar menu item</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</WindowLayoutActions>
</WindowLayoutContext.Provider>
</PortalProvider>,
),
);
}
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);
});
@@ -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 <PortalHost name={HOST} />;
}
/** 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 (
<Portal hostName={HOST}>
<View
testID="workspace-vertical-actions"
style={[
styles.bar,
{ left: bar.x, top: bar.y, width: bar.width, height: bar.height },
!active && styles.hidden,
]}
pointerEvents="box-none"
>
<RetainedPanelActivity active={active}>
<IconButtonChromeProvider value="header-island">{children}</IconButtonChromeProvider>
</RetainedPanelActivity>
</View>
</Portal>
);
}
const styles = StyleSheet.create({
hidden: { display: "none" },
bar: { position: "absolute", alignItems: "center", gap: 8 },
});
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext } from "react";
import { useWindowDimensions } from "react-native";
import type { WindowLayout } from "./model";
export const WindowLayoutContext = createContext<WindowLayout | null>(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;
}
@@ -0,0 +1,43 @@
import { useCallback, useState, type ReactNode } from "react";
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import { WindowLayoutContext } from "./context";
import 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<WindowLayout>) => void;
}
>("PaseoWindowLayout");
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
const [geometry, setGeometry] = useState<WindowLayout | null>(null);
const onGeometryChange = useCallback(
(event: NativeSyntheticEvent<WindowLayout>) => setGeometry(event.nativeEvent),
[],
);
return (
<WindowLayoutContext.Provider value={geometry}>
<View style={styles.root}>
<GeometryProbe
pointerEvents="none"
style={StyleSheet.absoluteFill}
onGeometryChange={onGeometryChange}
/>
{children}
</View>
</WindowLayoutContext.Provider>
);
}
const styles = StyleSheet.create({ root: { flex: 1 } });
+15
View File
@@ -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;
}
+65
View File
@@ -0,0 +1,65 @@
export interface WindowLayoutRect {
x: number;
y: number;
width: number;
height: number;
}
export interface WindowLayout {
bounds: WindowLayoutRect;
verticalBarEdge: "none" | "leading" | "trailing";
verticalBar: WindowLayoutRect | null;
divisions: WindowLayoutRect[];
}
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 };
}

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