Merge branch 'dev' into main

This commit is contained in:
Mohamed Boudra
2026-04-18 19:54:47 +07:00
326 changed files with 38386 additions and 7027 deletions
+3
View File
@@ -43,6 +43,9 @@ jobs:
- name: Build relay dependency
run: npm run build --workspace=@getpaseo/relay
- name: Build server dependency
run: npm run build --workspace=@getpaseo/server
- name: Typecheck all packages
run: npm run typecheck
@@ -0,0 +1,158 @@
# Attachment-Based Review Context Plan
## Goal
Use structured attachments as the source of truth for review context during agent creation.
This covers two related behaviors:
1. Provider-facing context for the agent prompt
2. Worktree checkout behavior when creating a worktree from a review item
The design should stay compatible with future forge support such as GitLab.
## Core Direction
- Do not parse the prompt to discover issue/PR intent.
- Do not introduce deeply nested transport objects for this flow.
- Make the attachment itself the discriminated union.
- Keep forge-specific checkout logic below the attachment layer.
- Let each agent provider translate attachments into its own prompt/input format.
## Initial Attachment Types
Two initial structured attachment types:
- `github_pr`
- `github_issue`
With separate MIME types:
- `application/github-pr`
- `application/github-issue`
Proposed wire shape:
```ts
type AgentAttachment =
| {
type: "github_pr";
mimeType: "application/github-pr";
number: number;
title: string;
url: string;
body?: string | null;
baseRefName?: string | null;
headRefName?: string | null;
}
| {
type: "github_issue";
mimeType: "application/github-issue";
number: number;
title: string;
url: string;
body?: string | null;
};
```
## Backward Compatibility
- Add new optional `attachments` fields; keep existing `images` fields.
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
- Malformed attachment entries should also be ignored safely.
- Old clients continue working because `attachments` is optional.
- New clients can send `attachments` without breaking older flows that still rely on `images`.
## Request-Level Changes
Add optional `attachments` to:
- `create_agent_request`
- `send_agent_message_request`
Existing `initialPrompt` remains plain user text.
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
## App Responsibilities
When the user selects a GitHub item:
- If it is a PR, create a `github_pr` attachment
- If it is an issue, create a `github_issue` attachment
- Include the attachment in the create-agent request
The UI can still render friendly labels and previews from the same data.
The app should stop treating PR/issue context as prompt-only metadata.
## Worktree Creation Behavior
During agent creation, if worktree creation is requested:
- Inspect normalized attachments
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
The important rule:
- attachment type identifies the review object
- server-side git logic decides how to check it out
This keeps the attachment contract simple while allowing fork-safe implementation details.
## Checkout Resolution
The attachment itself should not encode the full checkout strategy.
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
Examples of possible implementation strategies:
- `gh pr checkout`
- `git fetch origin refs/pull/<number>/head:<local-branch>`
- another GitHub-aware resolver if needed later
This is intentionally a server implementation detail, not part of the attachment schema.
## Provider Responsibilities
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
Examples:
- Claude: render attachment into text blocks
- Codex: inject as blocks or text
- OpenCode: send as file/resource-like input where appropriate
- ACP: convert to resource/text forms as supported
The session layer should not hardcode one translation strategy for all providers.
## Suggested Implementation Order
1. Add tolerant attachment schema and normalization in shared/server message handling.
2. Thread `attachments` through app -> daemon client -> session layer.
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
6. Optionally migrate image transport into the same attachment mechanism later.
## Files Likely Involved
- `packages/server/src/shared/messages.ts`
- `packages/server/src/client/daemon-client.ts`
- `packages/server/src/server/session.ts`
- `packages/server/src/server/worktree-session.ts`
- `packages/server/src/server/agent/agent-sdk-types.ts`
- `packages/server/src/server/agent/providers/claude-agent.ts`
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
- `packages/server/src/server/agent/providers/opencode-agent.ts`
- `packages/server/src/server/agent/providers/acp-agent.ts`
- `packages/app/src/screens/new-workspace-screen.tsx`
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
- `packages/app/src/contexts/session-context.tsx`
## Notes
- The current image path can remain in place initially.
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.
+46
View File
@@ -35,6 +35,52 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
Check `$PASEO_HOME/daemon.log` for trace-level logs.
### Database queries
Run arbitrary SQL against the SQLite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
## paseo.json service scripts
Every `scripts` entry with `"type": "service"` receives these environment variables:
| Variable | Value |
|---|---|
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
| `HOST` | Bind host for the service process. |
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
```json
{
"scripts": {
"web": {
"type": "service",
"command": "PORT=$PASEO_PORT npm run dev:web"
}
}
}
```
## Build sync gotchas
### Relay → Daemon
+31
View File
@@ -95,6 +95,37 @@ Two reusable flows handle Expo dev client screens after launch:
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
- `flows/dev-client.yaml` — same but without asserting a particular app route
### Reach the composer
`flows/land-in-chat.yaml` is the canonical "get into a chat" primitive. It `clearState`s, runs `launch.yaml`, taps the welcome screen's direct-connection option, types `127.0.0.1:6767`, submits, and waits for `message-input-root`. Compose any composer-level fixture on top of it:
```yaml
appId: sh.paseo
---
- runFlow: flows/land-in-chat.yaml
# ...your scenario here, starting from a ready composer
```
See `image-picker-repro.yaml` for an example.
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
### Inputs that Maestro types into
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
### Dropdowns that launch native presenters (iOS)
On iOS, when a dropdown menu (`DropdownMenu` / RN `Modal`) item needs to launch a native presenter like `PHPickerViewController` (image picker) or a `UIDocumentPicker`, the callback **must not fire while the `Modal` is still dismissing**. UIKit dismissal completion spans multiple frames beyond React unmount; launching a native presenter mid-dismissal leaves an invisible backdrop mounted that traps every subsequent touch.
`DropdownMenu` handles this by deferring the selected item's `onSelect` until `Modal.onDismiss` fires (UIKit-level dismissal complete), then adds a small extra buffer before invoking it. See `components/ui/dropdown-menu.tsx`'s `selectItem` / `flushPendingSelect`.
When building a new component that composes a dropdown with a native presenter, reuse this dropdown — do not invent a new timing shim.
## Self-verification loops
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
+204
View File
@@ -0,0 +1,204 @@
# Unistyles Gotchas
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
## How Updates Propagate
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
## Main Gotcha: `contentContainerStyle`
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
Avoid this pattern when the style depends on the theme:
```tsx
<ScrollView contentContainerStyle={styles.container} />
const styles = StyleSheet.create((theme) => ({
container: {
flexGrow: 1,
backgroundColor: theme.colors.surface0,
},
}));
```
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
## Fix Patterns
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
```tsx
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.contentContainer}>
{children}
</ScrollView>
</View>
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
contentContainer: {
flexGrow: 1,
padding: theme.spacing[4],
},
}));
```
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
```tsx
import { ScrollView } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
const ThemedScrollView = withUnistyles(ScrollView);
<ThemedScrollView
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
/>
```
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
```tsx
const { theme } = useUnistyles();
<ScrollView
contentContainerStyle={[
styles.contentContainer,
{ backgroundColor: theme.colors.surface0 },
]}
/>
```
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
## Hidden Sheet Content
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
```tsx
const { theme } = useUnistyles();
<Text style={[styles.title, { color: theme.colors.foreground }]}>
{title}
</Text>
```
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
## Memoized Style Objects
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
Avoid indirect keys like this:
```tsx
const { theme, rt } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
```
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
```tsx
const { theme } = useUnistyles();
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
```
If a style factory is cheap, skipping `useMemo` entirely is also fine.
## Static Theme Imports
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
Use `useUnistyles()` inside the component instead:
```tsx
const { theme } = useUnistyles();
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
```
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
## Adaptive Themes And Persisted Settings
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
1. Unistyles config starts with `adaptiveThemes: true`.
2. The device may report system light.
3. Settings load a persisted non-auto preference, such as dark.
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
## Debugging
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
```js
[
"react-native-unistyles/plugin",
{
root: "src",
debug: true,
},
],
```
Then rebuild the bundle and look for lines such as:
```text
src/components/welcome-screen.tsx: styles.container: [Theme]
```
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
For paint-layer bugs, use high-contrast probes:
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
2. Cold-restart the app, not just Fast Refresh.
3. Screenshot the simulator and sample pixels to see which color fills the area.
4. Remove the probes before committing.
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
## References
- [Unistyles v3 documentation](https://www.unistyl.es/)
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
- [Local research note: welcome theme split](</Users/moboudra/.paseo/notes/welcome-theme-split-research.md>)
+1 -1
View File
@@ -42,7 +42,7 @@ buildNpmPackage rec {
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
npmDepsHash = "sha256-kw9Lefo644oeeTJCvdFdeW4tbwVMQWqkIVaNonhqbNs=";
npmDepsHash = "sha256-Q8FCZ5cQs45ZL0XeaRzgZXmROmywT0YI/1Rqdlpbt/w=";
# Prevent onnxruntime-node's install script from running during automatic
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
+52 -104
View File
@@ -21,7 +21,10 @@
],
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
"@modelcontextprotocol/sdk": "^1.27.1",
"expo": "~54.0.33",
"react": "19.1.0",
"react-native": "0.81.5"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
@@ -29,8 +32,8 @@
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
}
},
@@ -9547,9 +9550,9 @@
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.6.tgz",
"integrity": "sha512-nNlJ7mdXFoq/7LMG3eJIncqjgXkpDJak3xO8Lb4yQmFI3XVI1nupPRjlYRY0ham1gLE0F/AWvKFChsKUfF5lOQ==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
"integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==",
"license": "MIT",
"engines": {
"node": ">= 20.19.4"
@@ -9701,12 +9704,12 @@
}
},
"node_modules/@react-native/community-cli-plugin": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.6.tgz",
"integrity": "sha512-oTwIheF4TU7NkfoHxwSQAKtIDx4SQEs2xufgM3gguY7WkpnhGa/BYA/A+hdHXfqEKJFKlHcXQu4BrV/7Sv1fhw==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz",
"integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==",
"license": "MIT",
"dependencies": {
"@react-native/dev-middleware": "0.81.6",
"@react-native/dev-middleware": "0.81.5",
"debug": "^4.4.0",
"invariant": "^2.2.4",
"metro": "^0.83.1",
@@ -9730,46 +9733,6 @@
}
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/debugger-frontend": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.6.tgz",
"integrity": "sha512-aGw28yzbtm25GQuuxNeVAT72tLuGoH0yh79uYOIZkvjI+5x1NjZyPrgiLZ2LlZi5dJdxfbz30p1zUcHvcAzEZw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/@react-native/dev-middleware": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.6.tgz",
"integrity": "sha512-mK2M3gJ25LtgtqxS1ZXe1vHrz8APOA79Ot/MpbLeovFgLu6YJki0kbO5MRpJagTd+HbesVYSZb/BhAsGN7QAXA==",
"license": "MIT",
"dependencies": {
"@isaacs/ttlcache": "^1.4.1",
"@react-native/debugger-frontend": "0.81.6",
"chrome-launcher": "^0.15.2",
"chromium-edge-launcher": "^0.2.0",
"connect": "^3.6.5",
"debug": "^4.4.0",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"open": "^7.0.3",
"serve-static": "^1.16.2",
"ws": "^6.2.3"
},
"engines": {
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/community-cli-plugin/node_modules/ws": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz",
"integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==",
"license": "MIT",
"dependencies": {
"async-limiter": "~1.0.0"
}
},
"node_modules/@react-native/debugger-frontend": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz",
@@ -9811,18 +9774,18 @@
}
},
"node_modules/@react-native/gradle-plugin": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.6.tgz",
"integrity": "sha512-atUItC5MZ6yaNaI0sbsoDwUdF+KMNZcMKBIrNhXlUyIj3x1AQ6Cf8CHHv6Qokn8ZFw+uU6GWmQSiOWYUbmi8Ag==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz",
"integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.4"
}
},
"node_modules/@react-native/js-polyfills": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.6.tgz",
"integrity": "sha512-P5MWH/9vM24XkJ1TasCq42DMLoCUjZVSppTn6VWv/cI65NDjuYEy7bUSaXbYxGTnqiKyPG5Y+ADymqlIkdSAcw==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz",
"integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==",
"license": "MIT",
"engines": {
"node": ">= 20.19.4"
@@ -9841,9 +9804,9 @@
"license": "MIT"
},
"node_modules/@react-native/virtualized-lists": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.6.tgz",
"integrity": "sha512-1RrZl3a7iCoAS2SGaRLjJPIn8bg/GLNXzqkIB2lufXcJsftu1umNLRIi17ZoDRejAWSd2pUfUtQBASo4R2mw4Q==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
"integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==",
"license": "MIT",
"dependencies": {
"invariant": "^2.2.4",
@@ -9853,7 +9816,7 @@
"node": ">= 20.19.4"
},
"peerDependencies": {
"@types/react": "^19.1.4",
"@types/react": "^19.1.0",
"react": "*",
"react-native": "*"
},
@@ -29159,9 +29122,9 @@
}
},
"node_modules/react": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.4.tgz",
"integrity": "sha512-DHINL3PAmPUiK1uszfbKiXqfE03eszdt5BpVSuEAHb5nfmNPwnsy7g39h2t8aXFc/Bv99GH81s+j8dobtD+jOw==",
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -29199,15 +29162,15 @@
}
},
"node_modules/react-dom": {
"version": "19.1.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.4.tgz",
"integrity": "sha512-s2868ab/xo2SI6H4106A7aFI8Mrqa4xC6HZT/pBzYyQ3cBLqa88hu47xYD8xf+uECleN698Awn7RCWlkTiKnqQ==",
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
},
"peerDependencies": {
"react": "^19.1.4"
"react": "^19.1.0"
}
},
"node_modules/react-error-boundary": {
@@ -29279,19 +29242,19 @@
}
},
"node_modules/react-native": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.6.tgz",
"integrity": "sha512-X/tI8GqfzVaa+zfbE4+lySNN5UzwBIAVRHVZPKymOny9Acc5GYYcjAcEVfG3AM4h920YALWoSl8favnDmQEWIg==",
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
"license": "MIT",
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.6",
"@react-native/codegen": "0.81.6",
"@react-native/community-cli-plugin": "0.81.6",
"@react-native/gradle-plugin": "0.81.6",
"@react-native/js-polyfills": "0.81.6",
"@react-native/normalize-colors": "0.81.6",
"@react-native/virtualized-lists": "0.81.6",
"@react-native/assets-registry": "0.81.5",
"@react-native/codegen": "0.81.5",
"@react-native/community-cli-plugin": "0.81.5",
"@react-native/gradle-plugin": "0.81.5",
"@react-native/js-polyfills": "0.81.5",
"@react-native/normalize-colors": "0.81.5",
"@react-native/virtualized-lists": "0.81.5",
"abort-controller": "^3.0.0",
"anser": "^1.4.9",
"ansi-regex": "^5.0.0",
@@ -29326,8 +29289,8 @@
"node": ">= 20.19.4"
},
"peerDependencies": {
"@types/react": "^19.1.4",
"react": "^19.1.4"
"@types/react": "^19.1.0",
"react": "^19.1.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -29670,31 +29633,16 @@
"node": ">=10"
}
},
"node_modules/react-native/node_modules/@react-native/codegen": {
"version": "0.81.6",
"resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.6.tgz",
"integrity": "sha512-9KoYRep/KDnELLLmIYTtIIEOClVUJ88pxWObb/0sjkacA7uL4SgfbAg7rWLURAQJWI85L1YS67IhdEqNNk1I7w==",
"license": "MIT",
"dependencies": {
"@babel/core": "^7.25.2",
"@babel/parser": "^7.25.3",
"glob": "^7.1.1",
"hermes-parser": "0.29.1",
"invariant": "^2.2.4",
"nullthrows": "^1.1.1",
"yargs": "^17.6.2"
},
"engines": {
"node": ">= 20.19.4"
},
"peerDependencies": {
"@babel/core": "*"
}
"node_modules/react-native/node_modules/@react-native/normalize-colors": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz",
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT"
},
"node_modules/react-native/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -34963,8 +34911,8 @@
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
+9 -5
View File
@@ -39,6 +39,7 @@
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
@@ -70,8 +71,8 @@
"get-port-cli": "^3.0.0",
"knip": "^5.82.1",
"patch-package": "^8.0.1",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"typescript": "^5.9.3"
},
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
@@ -95,11 +96,14 @@
"license": "AGPL-3.0-or-later",
"overrides": {
"lightningcss": "1.30.1",
"react": "19.1.4",
"react-dom": "19.1.4"
"react": "19.1.0",
"react-dom": "19.1.0"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@modelcontextprotocol/sdk": "^1.27.1"
"@modelcontextprotocol/sdk": "^1.27.1",
"expo": "~54.0.33",
"react": "19.1.0",
"react-native": "0.81.5"
}
}
+4 -9
View File
@@ -8,6 +8,7 @@ import {
createIdleAgent,
expectSessionRowVisible,
expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden,
openSessions,
openWorkspaceWithAgents,
primeAdditionalPage,
@@ -19,7 +20,7 @@ test.describe("Archive tab reconciliation", () => {
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
test.describe.configure({ timeout: 120_000 });
test.describe.configure({ timeout: 300_000 });
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("archive-tab-");
@@ -64,10 +65,7 @@ test.describe("Archive tab reconciliation", () => {
survivingAgentId: surviving.id,
});
await reloadWorkspace(passivePage, tempRepo.path);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceTabHidden(passivePage, archived.id);
} finally {
await passivePage.close();
}
@@ -93,10 +91,7 @@ test.describe("Archive tab reconciliation", () => {
await openSessions(page);
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
await reloadWorkspace(page, tempRepo.path);
await expectWorkspaceArchiveOutcome(page, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
});
await expectWorkspaceTabHidden(page, archived.id);
await expectWorkspaceArchiveOutcome(passivePage, {
archivedAgentId: archived.id,
survivingAgentId: surviving.id,
+49 -1
View File
@@ -1,6 +1,6 @@
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import net from "node:net";
@@ -182,6 +182,7 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
let daemonProcess: ChildProcess | null = null;
let metroProcess: ChildProcess | null = null;
let paseoHome: string | null = null;
let fakeGhBinDir: string | null = null;
let relayProcess: ChildProcess | null = null;
type OfferPayload = {
@@ -191,6 +192,47 @@ type OfferPayload = {
relay: { endpoint: string };
};
async function createFakeGhBin(): Promise<string> {
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
const ghPath = path.join(binDir, "gh");
await writeFile(
ghPath,
`#!/usr/bin/env node
const args = process.argv.slice(2);
if (args[0] === "auth" && args[1] === "status") {
process.exit(0);
}
if (args[0] === "pr" && args[1] === "list") {
console.log(JSON.stringify([
{
number: 515,
title: "Review selected start ref",
url: "https://github.com/getpaseo/paseo/pull/515",
state: "OPEN",
body: "Fixture pull request for app e2e.",
labels: [],
baseRefName: "main",
headRefName: "feature/start-from-pr"
}
]));
process.exit(0);
}
if (args[0] === "issue" && args[1] === "list") {
console.log("[]");
process.exit(0);
}
console.error("Unsupported fake gh invocation: " + args.join(" "));
process.exit(1);
`,
);
await chmod(ghPath, 0o755);
return binDir;
}
function stripAnsi(input: string): string {
return input.replace(/\u001b\[[0-9;]*m/g, "");
}
@@ -281,6 +323,7 @@ export default async function globalSetup() {
let relayPort = 0;
const metroPort = await getAvailablePort();
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
fakeGhBinDir = await createFakeGhBin();
let relayLineBuffer = createLineBuffer();
const metroLineBuffer = createLineBuffer();
const daemonLineBuffer = createLineBuffer();
@@ -298,6 +341,10 @@ export default async function globalSetup() {
await rm(paseoHome, { recursive: true, force: true });
paseoHome = null;
}
if (fakeGhBinDir) {
await rm(fakeGhBinDir, { recursive: true, force: true });
fakeGhBinDir = null;
}
};
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
@@ -482,6 +529,7 @@ export default async function globalSetup() {
cwd: serverDir,
env: {
...process.env,
PATH: `${fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: "srv_e2e_test_daemon",
PASEO_LISTEN: `0.0.0.0:${port}`,
+1 -6
View File
@@ -200,16 +200,11 @@ export const gotoHome = async (page: Page) => {
};
export const openSettings = async (page: Page) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
// Navigate through the real app control so route changes stay aligned with UI behavior.
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(settingsButton).toBeVisible();
await settingsButton.click();
await expect(page).toHaveURL(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
await expect(page).toHaveURL(/\/settings\/general$/);
};
export const setWorkingDirectory = async (page: Page, directory: string) => {
+32 -10
View File
@@ -27,10 +27,15 @@ type ArchiveTabDaemonClient = {
modeId: string;
cwd: string;
title: string;
initialPrompt: string;
initialPrompt?: string;
}): Promise<{ id: string }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
waitForAgentUpsert(
agentId: string,
predicate: (snapshot: { status: string }) => boolean,
timeout?: number,
): Promise<{ status: string }>;
};
function getDaemonPort(): string {
@@ -110,16 +115,17 @@ export async function createIdleAgent(
const created = await client.createAgent({
provider: "opencode",
model: "opencode/gpt-5-nano",
modeId: "default",
modeId: "bypassPermissions",
cwd: input.cwd,
title: input.title,
initialPrompt: "Reply with exactly READY.",
});
const finished = await client.waitForFinish(created.id, 120_000);
if (finished.status !== "idle") {
throw new Error(
`Expected agent ${created.id} to become idle, got ${finished.status}. Error: ${JSON.stringify((finished as Record<string, unknown>).error ?? "unknown")}`,
);
const snapshot = await client.waitForAgentUpsert(
created.id,
(agent) => agent.status === "idle",
30_000,
);
if (snapshot.status !== "idle") {
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
}
return {
id: created.id,
@@ -188,6 +194,16 @@ export async function openWorkspaceWithAgents(
const serverId = getServerId();
for (const agent of agents) {
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here (matching terminal-perf pattern).
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceTabVisible(page, agent.id);
}
@@ -254,13 +270,19 @@ export async function archiveAgentFromSessions(
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
}
// Long-press the row. Idle agents are archived immediately (no modal).
// Running/initializing agents show a confirmation modal instead.
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(900);
await page.mouse.up();
// If a confirmation modal appears (running agent), click the archive button.
const archiveButton = page.getByTestId("agent-action-archive").first();
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
await archiveButton.click();
const modalVisible = await archiveButton.isVisible().catch(() => false);
if (modalVisible) {
await archiveButton.click();
}
await expectSessionRowArchived(page, input.title);
}
+195
View File
@@ -0,0 +1,195 @@
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { createTempGitRepo } from "./workspace";
// ─── Navigation ────────────────────────────────────────────────────────────
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
/** Navigate to a workspace and wait for the tab bar to appear. */
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
const route = buildHostWorkspaceRoute(getServerId(), cwd);
await page.goto(route);
await waitForTabBar(page);
}
// ─── Tab bar queries ───────────────────────────────────────────────────────
/** Wait for the workspace tab bar to be visible. */
export async function waitForTabBar(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
timeout: 30_000,
});
}
/** Return all tab test IDs currently in the tab bar. */
export async function getTabTestIds(page: Page): Promise<string[]> {
const tabs = page.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
);
const count = await tabs.count();
const ids: string[] = [];
for (let i = 0; i < count; i++) {
const testId = await tabs.nth(i).getAttribute("data-testid");
if (testId) ids.push(testId);
}
return ids;
}
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
const ids = await getTabTestIds(page);
return ids.filter((id) => id.includes(kind)).length;
}
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
export async function getActiveTabTestId(page: Page): Promise<string | null> {
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
const activeTab = page
.locator(
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
)
.first();
if (await activeTab.isVisible().catch(() => false)) {
return activeTab.getAttribute("data-testid");
}
// Fallback: the tab with focused styling
return null;
}
// ─── Tab actions ───────────────────────────────────────────────────────────
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
export async function clickNewTabButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
export async function clickNewTerminalButton(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
export async function pressNewTabShortcut(page: Page): Promise<void> {
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await page.keyboard.press(`${modifier}+t`);
}
// ─── Tab bar assertions ───────────────────────────────────────────────────
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
export async function waitForLauncherPanel(page: Page): Promise<void> {
// No-op: the launcher panel no longer exists.
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertNewChatTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible();
}
/** Assert the new terminal button is visible in the tab bar. */
export async function assertTerminalTileVisible(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-new-terminal").first()).toBeVisible();
}
// ─── Tab creation actions ─────────────────────────────────────────────────
/** Click the new agent tab button to create a draft/chat tab. */
export async function clickNewChat(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-agent-tab");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
/** Click the new terminal button to create a terminal tab. */
export async function clickTerminal(page: Page): Promise<void> {
const button = page.getByTestId("workspace-new-terminal");
await expect(button).toBeVisible({ timeout: 10_000 });
await button.click();
}
// ─── Tab title assertions ──────────────────────────────────────────────────
/** Wait for any tab in the bar to display the given title text. */
export async function waitForTabWithTitle(
page: Page,
title: string | RegExp,
timeout = 30_000,
): Promise<void> {
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
await expect(
page
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
.filter({ hasText: matcher })
.first(),
).toBeVisible({ timeout });
}
/** Assert the new agent tab button is visible in the tab bar. */
export async function assertSingleNewTabButton(page: Page): Promise<void> {
const buttons = page.getByTestId("workspace-new-agent-tab");
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(1);
}
// ─── No-flash measurement ──────────────────────────────────────────────────
/**
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
* Returns elapsed milliseconds.
*/
export async function measureTileTransition(
page: Page,
clickAction: () => Promise<void>,
successLocator: ReturnType<Page["locator"]>,
timeout = 5_000,
): Promise<number> {
const start = Date.now();
await clickAction();
await expect(successLocator).toBeVisible({ timeout });
return Date.now() - start;
}
/**
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
* Returns all unique snapshots observed.
*/
export async function sampleTabsDuringTransition(
page: Page,
action: () => Promise<void>,
durationMs = 2_000,
intervalMs = 30,
): Promise<string[][]> {
const snapshots: string[][] = [];
const startSampling = async () => {
const start = Date.now();
while (Date.now() - start < durationMs) {
snapshots.push(await getTabTestIds(page));
await page.waitForTimeout(intervalMs);
}
};
const samplingPromise = startSampling();
await action();
await samplingPromise;
return snapshots;
}
// ─── Workspace setup ───────────────────────────────────────────────────────
/** Create a temp git repo and return its path with a cleanup function. */
export async function createWorkspace(
prefix = "launcher-e2e-",
): ReturnType<typeof createTempGitRepo> {
return createTempGitRepo(prefix);
}
+61 -38
View File
@@ -84,21 +84,6 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
return decodeWorkspaceIdFromPathSegment(match[1]);
}
function parseWorkspaceIdFromSidebarRowTestId(
testId: string,
input: { serverId: string; previousWorkspaceId: string },
): string | null {
const prefix = `sidebar-workspace-row-${input.serverId}:`;
if (!testId.startsWith(prefix)) {
return null;
}
const workspaceId = testId.slice(prefix.length).trim();
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
return null;
}
return workspaceId;
}
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
@@ -168,7 +153,7 @@ export async function createWorktreeViaDaemon(
};
}
export async function clickNewWorkspaceButton(
export async function openNewWorkspaceComposer(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
@@ -179,42 +164,80 @@ export async function clickNewWorkspaceButton(
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
await expect(button).toBeVisible({ timeout: 30_000 });
await button.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
timeout: 30_000,
});
}
export async function clickNewWorkspaceButton(
page: Page,
input: { projectKey: string; projectDisplayName: string },
): Promise<void> {
await openNewWorkspaceComposer(page, input);
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
}
export async function openStartingRefPicker(page: Page): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toBeVisible({ timeout: 30_000 });
await trigger.click();
}
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
await expect(branchRow).toBeVisible({ timeout: 30_000 });
await branchRow.click();
}
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
await expect(prRow).toBeVisible({ timeout: 30_000 });
await prRow.click();
}
export async function expectStartingRefPickerTriggerPr(
page: Page,
input: { number: number; title: string; headRef: string },
): Promise<void> {
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
await expect(trigger).toContainText(`#${input.number}`);
await expect(trigger).toContainText(input.title);
await expect(trigger).not.toContainText(input.headRef);
}
export async function expectComposerGithubAttachmentPill(
page: Page,
input: { number: number; title: string },
): Promise<void> {
const pills = page.getByTestId("composer-github-attachment-pill");
await expect(pills).toHaveCount(1);
await expect(pills.first()).toContainText(`#${input.number}`);
await expect(pills.first()).toContainText(input.title);
}
export async function assertNewWorkspaceSidebarAndHeader(
page: Page,
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
): Promise<{ workspaceId: string }> {
// Wait for URL to redirect to the newly created workspace.
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
let workspaceId: string | null = null;
const sidebarWorkspaceRows = page.locator(
`[data-testid^="sidebar-workspace-row-${input.serverId}:"]`,
);
const deadline = Date.now() + 30_000;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const sidebarRowTestIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? ""),
);
workspaceId =
sidebarRowTestIds
.map((testId) => parseWorkspaceIdFromSidebarRowTestId(testId, input))
.find((id) => id !== null) ?? null;
if (workspaceId) {
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
break;
}
await page.waitForTimeout(250);
}
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
const sidebarWorkspaceRowIds = await sidebarWorkspaceRows.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? "<missing-testid>"),
);
throw new Error(
[
"Expected a newly created workspace to load.",
`Current URL: ${page.url()}`,
`Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || "<none>"}`,
].join("\n"),
);
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
}
const createdWorkspaceRow = page.getByTestId(
+18 -10
View File
@@ -8,6 +8,10 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
export type TerminalPerfDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: { id: string; name: string; projectRootPath: string } | null;
error: string | null;
}>;
createTerminal(
cwd: string,
name?: string,
@@ -79,14 +83,14 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
return client;
}
export function buildTerminalWorkspaceUrl(cwd: string, terminalId: string): string {
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, cwd);
const route = buildHostWorkspaceRoute(serverId, workspaceId);
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
}
function buildWorkspaceUrl(cwd: string): string {
return buildHostWorkspaceRoute(getServerId(), cwd);
function buildWorkspaceUrl(workspaceId: string): string {
return buildHostWorkspaceRoute(getServerId(), workspaceId);
}
export async function getTerminalBufferText(page: Page): Promise<string> {
@@ -125,31 +129,35 @@ export async function waitForTerminalContent(
export async function navigateToTerminal(
page: Page,
input: { cwd: string; terminalId: string },
input: { workspaceId: string; terminalId: string },
): Promise<void> {
// Boot the app at the workspace route directly.
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
// so the daemon registry is already configured when the app starts.
const workspaceRoute = buildTerminalWorkspaceUrl(input.cwd, input.terminalId);
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
await page.goto(workspaceRoute);
// The workspace layout consumes `?open=...`, returns null during the effect,
// then replaces the URL with the clean workspace route after preparing the tab.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.cwd);
// On CI, Expo Router's rootNavigationState may take time to initialize,
// so we allow a generous timeout here.
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
await page.waitForURL(
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
{ timeout: 15_000 },
{ timeout: 30_000 },
);
// Wait for daemon connection (sidebar shows host label)
await page
.getByText("localhost", { exact: true })
.first()
.waitFor({ state: "visible", timeout: 15_000 });
.waitFor({ state: "visible", timeout: 30_000 });
// The open intent should have prepared and focused the exact pre-created terminal tab.
// The tab reconciliation effect also auto-creates terminal tabs once hydration completes,
// so we give it enough time for the full workspace hydration + tab creation cycle.
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
await terminalTab.waitFor({ state: "visible", timeout: 15_000 });
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
await terminalTab.click();
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
@@ -0,0 +1,32 @@
import { expect, type Page } from "@playwright/test";
import { clickNewChat, clickTerminal } from "./launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
function terminalSurface(page: Page) {
return page.locator('[data-testid="terminal-surface"]').first();
}
function composerInput(page: Page) {
return page.getByRole("textbox", { name: "Message agent..." }).first();
}
export async function expectTerminalCwd(page: Page, expectedPath: string): Promise<void> {
const terminal = terminalSurface(page);
await expect(terminal).toBeVisible({ timeout: 20_000 });
await terminal.click();
await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`);
await terminal.pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000);
}
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
await clickTerminal(page);
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
}
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
await clickNewChat(page);
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
}
+341
View File
@@ -0,0 +1,341 @@
import { realpathSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import type { SessionOutboundMessage } from "@server/shared/messages";
type WorkspaceSetupDaemonClient = {
connect(): Promise<void>;
close(): Promise<void>;
openProject(cwd: string): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
workspace: {
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
fetchWorkspaces(): Promise<{
entries: Array<{
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}>;
}>;
fetchAgents(): Promise<{
entries: Array<{
agent: { id: string; cwd: string; workspaceId?: string | null };
}>;
}>;
fetchAgent(agentId: string): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown | null;
} | null>;
listTerminals(cwd: string): Promise<{
cwd?: string;
terminals: Array<{ id: string; cwd: string; name: string }>;
error?: string | null;
}>;
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
};
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
export type { WorkspaceSetupDaemonClient };
function getDaemonWsUrl(): string {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error("E2E_DAEMON_PORT is not set.");
}
return `ws://127.0.0.1:${daemonPort}/ws`;
}
async function loadDaemonClientConstructor(): Promise<
new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient
> {
const repoRoot = path.resolve(process.cwd(), "../..");
const moduleUrl = pathToFileURL(
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
).href;
const mod = (await import(moduleUrl)) as {
DaemonClient: new (config: {
url: string;
clientId: string;
clientType: "cli";
webSocketFactory?: NodeWebSocketFactory;
}) => WorkspaceSetupDaemonClient;
};
return mod.DaemonClient;
}
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const DaemonClient = await loadDaemonClientConstructor();
const webSocketFactory = createNodeWebSocketFactory();
const client = new DaemonClient({
url: getDaemonWsUrl(),
clientId: `workspace-setup-${randomUUID()}`,
clientType: "cli",
webSocketFactory,
});
await client.connect();
return client;
}
export async function seedProjectForWorkspaceSetup(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<void> {
const result = await client.openProject(repoPath);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
}
}
export function projectNameFromPath(repoPath: string): string {
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
}
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
await gotoAppShell(page);
await expect(
page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectNameFromPath(repoPath) })
.first(),
).toBeVisible({ timeout: 30_000 });
}
function createWorkspaceButton(page: Page, repoPath: string) {
return page.getByRole("button", {
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
});
}
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
await page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectNameFromPath(repoPath) })
.first()
.hover();
}
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
const button = createWorkspaceButton(page, repoPath);
await revealWorkspaceButton(page, repoPath);
await expect(button).toBeVisible({ timeout: 30_000 });
await expect(button).toBeEnabled({ timeout: 30_000 });
await button.click();
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
}
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
await expect
.poll(
() => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null,
{ timeout: 30_000 },
)
.not.toBeNull();
const workspaceId =
parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null;
if (!workspaceId) {
throw new Error(`Expected a workspace route but found ${page.url()}`);
}
return workspaceId;
}
function workspaceSetupDialog(page: Page) {
return page.getByTestId("workspace-setup-dialog");
}
export async function createChatAgentFromWorkspaceSetup(
page: Page,
input: { message: string },
): Promise<void> {
const messageInput = page.getByRole("textbox", { name: "Message agent..." }).first();
await expect(messageInput).toBeVisible({ timeout: 15_000 });
await messageInput.fill(input.message);
await messageInput.press("Enter");
}
/**
* @deprecated The new workspace screen no longer has a standalone terminal button.
* Use the daemon API to create a workspace, then open a terminal from the launcher.
*/
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
await workspaceSetupDialog(page)
.getByRole("button", { name: /^Terminal Create the workspace/i })
.click();
}
export async function waitForWorkspaceSetupDialogToClose(
page: Page,
timeoutMs = 45_000,
): Promise<void> {
const dialog = workspaceSetupDialog(page);
try {
await expect(dialog).toHaveCount(0, { timeout: timeoutMs });
} catch (error) {
const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim();
throw new Error(
dialogText
? `Workspace setup dialog stayed open. Visible text: ${dialogText}`
: `Workspace setup dialog did not close within ${timeoutMs}ms`,
{ cause: error },
);
}
}
export async function expectSetupPanel(page: Page): Promise<void> {
// If the setup panel is already visible (auto-opened), we're done.
const panel = page.getByTestId("workspace-setup-panel");
if (await panel.isVisible().catch(() => false)) {
return;
}
// Otherwise open it manually via workspace header actions menu.
// Use the specific testID to avoid matching the sidebar kebab which shares
// the same "Workspace actions" accessibility label.
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
await expect(actionsButton).toBeVisible({ timeout: 10_000 });
await actionsButton.click();
const showSetup = page.getByTestId("workspace-header-show-setup");
await expect(showSetup).toBeVisible({ timeout: 5_000 });
await showSetup.click();
await expect(panel).toBeVisible({ timeout: 30_000 });
}
export async function expectSetupStatus(
page: Page,
status: "Running" | "Completed" | "Failed",
): Promise<void> {
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
timeout: 30_000,
});
}
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
timeout: 30_000,
});
}
export async function expectNoSetupMessage(page: Page): Promise<void> {
await expect(
page.getByText("No setup commands ran for this workspace.", { exact: true }),
).toBeVisible({
timeout: 30_000,
});
}
export async function createWorkspaceThroughDaemon(
client: WorkspaceSetupDaemonClient,
input: { cwd: string; worktreeSlug: string },
): Promise<{ id: string; name: string }> {
const result = await client.createPaseoWorktree(input);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
}
return {
id: String(result.workspace.id),
name: result.workspace.name,
};
}
export async function findWorktreeWorkspaceForProject(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<{
id: string;
name: string;
projectRootPath: string;
workspaceDirectory: string;
}> {
const payload = await client.fetchWorkspaces();
const normalizedRepoPath = realpathSync(repoPath);
const workspace =
payload.entries.find(
(entry) =>
entry.projectRootPath === normalizedRepoPath &&
entry.workspaceDirectory !== normalizedRepoPath,
) ?? null;
if (!workspace) {
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
}
return {
id: String(workspace.id),
name: workspace.name,
projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory,
};
}
export async function fetchWorkspaceById(
client: WorkspaceSetupDaemonClient,
workspaceId: string,
): Promise<{
id: string;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}> {
const payload = await client.fetchWorkspaces();
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return workspace;
}
export async function waitForWorkspaceSetupProgress(
client: WorkspaceSetupDaemonClient,
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
timeoutMs = 30_000,
): Promise<WorkspaceSetupProgressPayload> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
}, timeoutMs);
const unsubscribe = client.subscribeRawMessages((message) => {
if (message.type !== "workspace_setup_progress") {
return;
}
if (!predicate(message.payload)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(message.payload);
});
});
}
@@ -22,6 +22,28 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
});
}
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
const allTabIds = await getWorkspaceTabTestIds(page);
return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_"));
}
export async function expectOnlyWorkspaceAgentTabsVisible(
page: Page,
expectedAgentIds: string[],
): Promise<void> {
const expected = new Set(expectedAgentIds.map((id) => `workspace-tab-agent_${id}`));
const visible = await getVisibleWorkspaceAgentTabIds(page);
const unexpected = visible.filter((id) => !expected.has(id));
expect(unexpected).toEqual([]);
expect(visible.length).toBe(expected.size);
for (const expectedId of expectedAgentIds) {
await expect(page.getByTestId(`workspace-tab-agent_${expectedId}`)).toBeVisible({
timeout: 30_000,
});
}
}
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
const toggle = page.getByTestId("workspace-explorer-toggle").first();
if (!(await toggle.isVisible().catch(() => false))) {
+49
View File
@@ -6,6 +6,17 @@ export async function openNewAgentComposer(page: Page): Promise<void> {
await gotoHome(page);
}
/**
* Wait for the sidebar to show at least one project row, indicating that the
* WebSocket connection is up and workspace hydration has completed.
*/
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
await page
.locator('[data-testid^="sidebar-project-row-"]')
.first()
.waitFor({ state: "visible", timeout });
}
export function workspaceLabelFromPath(value: string): string {
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/").filter(Boolean);
@@ -35,6 +46,29 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string
return page.locator(ids.join(",")).first();
}
export async function expectSidebarWorkspaceSelected(input: {
page: Page;
serverId: string;
workspaceId: string;
selected?: boolean;
}): Promise<void> {
const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId);
await expect(row).toBeVisible({ timeout: 30_000 });
const expected = input.selected === false ? "false" : "true";
const hasDataSelected = await row.getAttribute("data-selected");
if (hasDataSelected !== null) {
await expect(row).toHaveAttribute("data-selected", expected, {
timeout: 30_000,
});
return;
}
await expect(row).toHaveAttribute("aria-selected", expected, {
timeout: 30_000,
});
}
export async function switchWorkspaceViaSidebar(input: {
page: Page;
serverId: string;
@@ -50,6 +84,21 @@ export async function switchWorkspaceViaSidebar(input: {
});
}
/**
* Wait for a workspace's sidebar row to appear, confirming the workspace
* descriptor has been hydrated into the session store.
*/
export async function waitForWorkspaceInSidebar(
page: Page,
input: { serverId: string; workspaceId: string },
): Promise<void> {
const candidates = candidateWorkspaceIds(input.workspaceId);
const selector = candidates
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`)
.join(",");
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
}
export async function expectWorkspaceHeader(
page: Page,
input: { title: string; subtitle: string },
+81 -4
View File
@@ -1,19 +1,26 @@
import { execSync } from "node:child_process";
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
type TempRepo = {
path: string;
branchHeads: Record<string, string>;
cleanup: () => Promise<void>;
};
export const createTempGitRepo = async (
prefix = "paseo-e2e-",
options?: { withRemote?: boolean },
options?: {
withRemote?: boolean;
paseoConfig?: Record<string, unknown>;
files?: Array<{ path: string; content: string }>;
branches?: string[];
},
): Promise<TempRepo> => {
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
const withRemote = options?.withRemote ?? false;
@@ -22,22 +29,92 @@ export const createTempGitRepo = async (
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
if (options?.paseoConfig) {
await writeFile(
path.join(repoPath, "paseo.json"),
JSON.stringify(options.paseoConfig, null, 2),
);
}
for (const file of options?.files ?? []) {
const filePath = path.join(repoPath, file.path);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, file.content);
}
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
if (options?.paseoConfig) {
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
}
for (const file of options?.files ?? []) {
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
}
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
const branchHeads: Record<string, string> = {};
const branches = Array.from(new Set(options?.branches ?? []));
for (const branch of branches) {
if (branch !== "main") {
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
cwd: repoPath,
stdio: "ignore",
});
}
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
cwd: repoPath,
stdio: "ignore",
});
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
cwd: repoPath,
stdio: "pipe",
})
.toString()
.trim();
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
}
if (withRemote) {
// Deterministic local remote to avoid relying on external auth/network in e2e.
const remoteDir = path.join(repoPath, "remote.git");
await mkdir(remoteDir, { recursive: true });
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin main", { cwd: repoPath, stdio: "ignore" });
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
}
return {
path: repoPath,
branchHeads,
cleanup: async () => {
await rm(repoPath, { recursive: true, force: true });
},
};
};
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
currentBranch: string;
hasAncestor: (ref: string) => boolean;
}> {
const currentBranch = execSync("git branch --show-current", {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
return {
currentBranch,
hasAncestor: (ref: string) => {
try {
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
cwd: worktreePath,
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
};
}
+258
View File
@@ -0,0 +1,258 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
gotoWorkspace,
assertNewChatTileVisible,
assertTerminalTileVisible,
assertSingleNewTabButton,
clickNewTabButton,
pressNewTabShortcut,
clickNewChat,
clickTerminal,
countTabsOfKind,
getTabTestIds,
waitForTabWithTitle,
measureTileTransition,
sampleTabsDuringTransition,
} from "./helpers/launcher";
import {
connectTerminalClient,
waitForTerminalContent,
setupDeterministicPrompt,
type TerminalPerfDaemonClient,
} from "./helpers/terminal-perf";
// ─── Shared state ──────────────────────────────────────────────────────────
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
let seedClient: TerminalPerfDaemonClient;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("launcher-e2e-");
seedClient = await connectTerminalClient();
const result = await seedClient.openProject(tempRepo.path);
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
workspaceId = String(result.workspace.id);
});
test.afterAll(async () => {
if (seedClient) await seedClient.close();
if (tempRepo) await tempRepo.cleanup();
});
// ═══════════════════════════════════════════════════════════════════════════
// Tab Creation Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab creation", () => {
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await pressNewTabShortcut(page);
// Should show the composer directly (no launcher panel)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
});
test("opening two new tabs creates two draft tabs", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
const countBefore = await countTabsOfKind(page, "draft");
await pressNewTabShortcut(page);
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countBefore + 1);
const countAfterFirst = await countTabsOfKind(page, "draft");
// Blur the composer so the second shortcut isn't swallowed by the focused input
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.());
await pressNewTabShortcut(page);
await expect
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
.toBe(countAfterFirst + 1);
});
test("clicking new agent tab creates a draft tab", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await clickNewTabButton(page);
// Draft composer should appear (the agent message input)
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
const tabsAfter = await getTabTestIds(page);
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
});
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
test.setTimeout(45_000);
await gotoWorkspace(page, workspaceId);
await clickTerminal(page);
// Terminal surface should appear
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
const tabsAfter = await getTabTestIds(page);
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
});
test("tab bar shows action buttons per pane", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
await assertSingleNewTabButton(page);
await assertNewChatTileVisible(page);
await assertTerminalTileVisible(page);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// Terminal Title Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Terminal title propagation", () => {
// OSC title escape sequence propagation is inherently flaky — the terminal
// must process the sequence, emit a title change event, and the tab bar
// must re-render before the assertion deadline. Allow retries.
test.describe.configure({ retries: 2 });
let client: TerminalPerfDaemonClient;
test.beforeAll(async () => {
client = await connectTerminalClient();
});
test.afterAll(async () => {
if (client) await client.close();
});
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "title-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
// Navigate to workspace and open a terminal
await gotoWorkspace(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Send OSC 0 (set window title) escape sequence
const testTitle = `E2E-Title-${Date.now()}`;
await terminal
.first()
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
// Wait for the tab to reflect the new title
await waitForTabWithTitle(page, testTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
test.setTimeout(60_000);
const result = await client.createTerminal(tempRepo.path, "debounce-test");
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
const terminalId = result.terminal.id;
try {
await gotoWorkspace(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page);
// Fire many rapid title changes — only the last should stick
const finalTitle = `Final-${Date.now()}`;
for (let i = 0; i < 5; i++) {
await terminal
.first()
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
}
await terminal
.first()
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
// The tab should eventually settle on the final title
await waitForTabWithTitle(page, finalTitle, 15_000);
} finally {
await client.killTerminal(terminalId).catch(() => {});
}
});
});
// ═══════════════════════════════════════════════════════════════════════════
// No-Flash Transition Tests
// ═══════════════════════════════════════════════════════════════════════════
test.describe("Tab transitions (no flash)", () => {
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
// Sample tabs at high frequency across the transition
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
// Every snapshot should have at least one tab — no blank/zero-tab frames
for (const snapshot of snapshots) {
expect(snapshot.length).toBeGreaterThanOrEqual(1);
}
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
// When running in-suite, previous tests may have created tabs on the shared workspace,
// so we allow +2 tolerance for accumulated state and React render batching.
const counts = snapshots.map((s) => s.length);
const maxCount = Math.max(...counts);
const initialCount = counts[0] ?? 0;
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
});
test("Terminal transition completes within visual budget", async ({ page }) => {
test.setTimeout(30_000);
await gotoWorkspace(page, workspaceId);
const terminal = page.locator('[data-testid="terminal-surface"]');
const elapsed = await measureTileTransition(
page,
() => clickTerminal(page),
terminal.first(),
20_000,
);
// Terminal surface should appear within a reasonable budget.
// Note: terminal creation involves a server round-trip, so we allow more time
// than a pure in-memory transition, but it should still be well under 5 seconds.
expect(elapsed).toBeLessThan(5_000);
});
test("New agent tab click shows composer without flash", async ({ page }) => {
await gotoWorkspace(page, workspaceId);
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
// Draft creation is fully in-memory — should be fast
// We use a generous budget here because CI can be slow, but the key assertion
// is that no blank/flash frame appears (tested above).
expect(elapsed).toBeLessThan(3_000);
});
});
+216 -14
View File
@@ -1,5 +1,8 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
@@ -7,12 +10,21 @@ import {
clickNewWorkspaceButton,
connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon,
expectComposerGithubAttachmentPill,
expectStartingRefPickerTriggerPr,
openNewWorkspaceComposer,
openStartingRefPicker,
openProjectViaDaemon,
selectBranchInPicker,
selectGitHubPrInPicker,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
waitForWorkspaceInSidebar,
workspaceLabelFromPath,
} from "./helpers/workspace-ui";
@@ -21,7 +33,7 @@ test.describe("New workspace flow", () => {
const localWorkspaceIds = new Set<string>();
const createdWorktreeIds = new Set<string>();
test.describe.configure({ timeout: 120_000 });
test.describe.configure({ timeout: 240_000 });
test.beforeEach(async () => {
client = await connectNewWorkspaceDaemonClient();
@@ -56,11 +68,17 @@ test.describe("New workspace flow", () => {
localWorkspaceIds.add(firstWorkspace.workspaceId);
localWorkspaceIds.add(secondWorkspace.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
subtitle: firstWorkspace.projectDisplayName,
});
await switchWorkspaceViaSidebar({
@@ -68,9 +86,13 @@ test.describe("New workspace flow", () => {
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
});
await waitForWorkspaceInSidebar(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(secondRepo.path),
subtitle: secondWorkspace.projectDisplayName,
});
await switchWorkspaceViaSidebar({
@@ -80,7 +102,7 @@ test.describe("New workspace flow", () => {
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: workspaceLabelFromPath(firstRepo.path),
subtitle: firstWorkspace.projectDisplayName,
});
} finally {
await secondRepo.cleanup();
@@ -88,6 +110,87 @@ test.describe("New workspace flow", () => {
}
});
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const repo = await createTempGitRepo("workspace-nav-same-project-");
try {
const rootWorkspace = await openProjectViaDaemon(client, repo.path);
const worktreeWorkspace = await createWorktreeViaDaemon(client, {
cwd: repo.path,
slug: `nav-${Date.now()}`,
});
localWorkspaceIds.add(rootWorkspace.workspaceId);
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
subtitle: rootWorkspace.projectDisplayName,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: rootWorkspace.workspaceId,
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: worktreeWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: worktreeWorkspace.workspaceName,
subtitle: worktreeWorkspace.projectDisplayName,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: worktreeWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: rootWorkspace.workspaceId,
selected: false,
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: rootWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: rootWorkspace.workspaceName,
subtitle: rootWorkspace.projectDisplayName,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: rootWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: worktreeWorkspace.workspaceId,
selected: false,
});
} finally {
await repo.cleanup();
}
});
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one draft tab", async ({
page,
}) => {
@@ -102,11 +205,17 @@ test.describe("New workspace flow", () => {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await page.goto(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, openedProject.workspaceId));
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: workspaceLabelFromPath(tempRepo.path),
subtitle: openedProject.projectDisplayName,
});
await clickNewWorkspaceButton(page, {
@@ -139,10 +248,13 @@ test.describe("New workspace flow", () => {
subtitle: openedProject.projectDisplayName,
});
const draftTabs = page.locator('[data-testid^="workspace-tab-"]').filter({
has: page.getByText("New Agent", { exact: true }),
});
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
const agentTabs = page.locator('[data-testid^="workspace-tab-agent_"]');
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
// Workspace setup may auto-open a setup tab that steals focus,
// hiding the agent panel (display:none removes it from the
// accessibility tree). Click the agent tab to ensure it's active.
await agentTabs.first().click();
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(composer).toBeEditable({ timeout: 30_000 });
@@ -150,4 +262,94 @@ test.describe("New workspace flow", () => {
await tempRepo.cleanup();
}
});
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
branches: ["main", "dev"],
});
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: openedProject.workspaceId,
});
await expectWorkspaceHeader(page, {
title: openedProject.workspaceName,
subtitle: openedProject.projectDisplayName,
});
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectBranchInPicker(page, "dev");
const createButton = page
.getByTestId("message-input-root")
.getByRole("button", { name: "Create" });
await expect(createButton).toBeVisible({ timeout: 30_000 });
await createButton.click();
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
serverId,
previousWorkspaceId: openedProject.workspaceId,
projectDisplayName: openedProject.projectDisplayName,
});
createdWorktreeIds.add(createdWorkspace.workspaceId);
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
const branchInfo = await readWorktreeBranchInfo({
worktreePath: createdWorkspace.workspaceId,
});
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
} finally {
await tempRepo.cleanup();
}
});
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
try {
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
localWorkspaceIds.add(openedProject.workspaceId);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: openedProject.projectKey,
projectDisplayName: openedProject.projectDisplayName,
});
await openStartingRefPicker(page);
await selectGitHubPrInPicker(page, 515);
await expectStartingRefPickerTriggerPr(page, {
number: 515,
title: "Review selected start ref",
headRef: "feature/start-from-pr",
});
await expectComposerGithubAttachmentPill(page, {
number: 515,
title: "Review selected start ref",
});
} finally {
await tempRepo.cleanup();
}
});
});
+179
View File
@@ -0,0 +1,179 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
function getSeededServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
function getSeededDaemonPort(): string {
const port = process.env.E2E_DAEMON_PORT;
if (!port) {
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
}
return port;
}
async function openHostPage(page: Page, serverId: string) {
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
}
async function expectHostLabelHeader(page: Page) {
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
}
test.describe("Settings host page", () => {
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
page,
}) => {
const serverId = getSeededServerId();
const port = getSeededDaemonPort();
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
await expectHostLabelHeader(page);
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
const connectionsCard = page.getByTestId("host-page-connections-card");
await expect(connectionsCard).toBeVisible();
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
await expect(
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
).toBeVisible();
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
await expect(injectMcpCard).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "On", exact: true })).toBeVisible();
await expect(injectMcpCard.getByRole("button", { name: "Off", exact: true })).toBeVisible();
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
});
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
const serverId = getSeededServerId();
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
await page.getByTestId("host-page-label-edit-button").click();
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
});
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
page,
}) => {
const serverId = getSeededServerId();
await gotoAppShell(page);
await openSettings(page);
await openHostPage(page, serverId);
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
// Pair-device now lives behind a row that only the local host sees
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
// not appear. The daemon-lifecycle card is still local-host only.
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
});
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
});
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
page,
}) => {
const serverId = getSeededServerId();
await gotoAppShell(page);
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expectHostLabelHeader(page);
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
});
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
const serverId = getSeededServerId();
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
// seeded host to the local daemon. `manageBuiltInDaemon: false` bypasses
// the desktop bootstrap flow so only the sidebar's status query runs.
await page.addInitScript((localServerId) => {
localStorage.setItem(
"@paseo:app-settings",
JSON.stringify({ theme: "auto", manageBuiltInDaemon: false, sendBehavior: "interrupt" }),
);
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "darwin",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localServerId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
};
}, serverId);
await gotoAppShell(page);
await openSettings(page);
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible({ timeout: 15000 });
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
await expect(hostEntries.first()).toHaveAttribute(
"data-testid",
`settings-host-entry-${serverId}`,
);
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
});
});
@@ -0,0 +1,186 @@
import { test, expect, type Page } from "./fixtures";
import { gotoAppShell, openSettings } from "./helpers/app";
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
async function clickSidebarSection(page: Page, label: string) {
const sidebar = page.getByTestId("settings-sidebar");
await expect(sidebar).toBeVisible();
await sidebar.getByRole("button", { name: label, exact: true }).click();
}
test.describe("Settings sidebar navigation", () => {
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await clickSidebarSection(page, "Diagnostics");
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
await clickSidebarSection(page, "About");
await expect(page).toHaveURL(/\/settings\/about$/);
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
await clickSidebarSection(page, "General");
await expect(page).toHaveURL(/\/settings\/general$/);
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
});
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId();
await gotoAppShell(page);
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
});
test("the + Add host button opens the add-host method modal", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await page.getByTestId("settings-add-host").click();
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
});
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
const backRow = page.getByTestId("settings-back-to-workspace");
await expect(backRow).toBeVisible();
await backRow.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
});
test.describe("Settings — compact master-detail", () => {
test.use({ viewport: { width: 390, height: 844 } });
async function openCompactSettingsRoot(page: Page) {
await gotoAppShell(page);
// Wait for bootstrap to settle on a host route so storeReady is true before
// we navigate into the protected settings stack. A direct page.goto("/settings")
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
// Drive navigation the same way a user would: open the mobile drawer and tap
// the Settings footer icon. This preserves the in-app router state instead of
// triggering a full reload through Stack.Protected.
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
await expect(sidebarSettingsButton).toBeVisible();
await sidebarSettingsButton.click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
}
async function expectCompactSettingsRootList(page: Page) {
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
}
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
await openCompactSettingsRoot(page);
// Sidebar rows are present.
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
).toBeVisible();
await expect(
page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true }),
).toBeVisible();
await expect(
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
).toBeVisible();
// Section detail content is NOT rendered at the root.
await expectCompactSettingsRootList(page);
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
await expect(rootBackButton).toBeVisible();
await rootBackButton.click();
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
});
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
await openCompactSettingsRoot(page);
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "Diagnostics", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
// Sidebar is no longer visible — we are on a detail screen.
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
// only visible instances.)
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("back from a section detail returns to the /settings list", async ({ page }) => {
await openCompactSettingsRoot(page);
await page
.getByTestId("settings-sidebar")
.getByRole("button", { name: "About", exact: true })
.click();
await expect(page).toHaveURL(/\/settings\/about$/);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expectCompactSettingsRootList(page);
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
const serverId = getServerId();
await openCompactSettingsRoot(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
});
test("back from a host detail returns to the /settings list", async ({ page }) => {
const serverId = getServerId();
await openCompactSettingsRoot(page);
await page.getByTestId(`settings-host-entry-${serverId}`).click();
await expect(page).toHaveURL(
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
);
await page.getByRole("button", { name: "Back", exact: true }).click();
await expect(page).toHaveURL(/\/settings$/);
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
});
});
+182
View File
@@ -0,0 +1,182 @@
import { execSync } from "node:child_process";
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test, expect } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
}
return serverId;
}
function getWorkspaceRowTestId(workspaceId: string): string {
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function setGitHubRemote(repoPath: string): void {
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
cwd: repoPath,
stdio: "ignore",
});
}
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
const dirPath = await mkdtemp(path.join(tempRoot, prefix));
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
return {
path: dirPath,
cleanup: async () => {
await rm(dirPath, { recursive: true, force: true });
},
};
}
async function openProjectViaDaemon(
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
cwd: string,
): Promise<{ id: string; name: string }> {
const result = await client.openProject(cwd);
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${cwd}`);
}
return {
id: String(result.workspace.id),
name: result.workspace.name,
};
}
async function openWorkspaceFromSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
) {
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
return row;
}
async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
const row = page
.getByRole("button", {
name: new RegExp(escapeRegex(projectName), "i"),
})
.first();
await expect(row).toBeVisible({ timeout: 30_000 });
return row;
}
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
await expect(row).toBeVisible({ timeout: 30_000 });
return row;
}
test.describe("Sidebar workspace list", () => {
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
const projectRow = page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: "test-owner/test-repo" })
.first();
await expect(projectRow).toBeVisible({ timeout: 30_000 });
await expect(projectRow).not.toContainText(path.basename(repo.path));
} finally {
await client.close();
await repo.cleanup();
}
});
test("project shows workspace under it", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
await waitForSidebarWorkspace(page, workspace.id);
} finally {
await client.close();
await repo.cleanup();
}
});
test("non-git project shows directory name", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const project = await createTempDirectory("sidebar-directory-");
try {
await openProjectViaDaemon(client, project.path);
await gotoAppShell(page);
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
await expect(projectRow).toContainText(path.basename(project.path));
} finally {
await client.close();
await project.cleanup();
}
});
test("workspace header shows correct title and subtitle", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
try {
setGitHubRemote(repo.path);
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, "test-owner/test-repo");
await waitForSidebarWorkspace(page, workspace.id);
await openWorkspaceFromSidebar(page, workspace.id);
await expectWorkspaceHeader(page, {
title: workspace.name,
subtitle: "test-owner/test-repo",
});
} finally {
await client.close();
await repo.cleanup();
}
});
test("git project shows branch name in workspace row", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("sidebar-branch-");
try {
const workspace = await openProjectViaDaemon(client, repo.path);
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(repo.path));
expect(workspace.name).toBe("main");
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -20,10 +20,15 @@ const KEYSTROKE_P95_BUDGET_MS = 150;
test.describe("Terminal wire performance", () => {
let client: TerminalPerfDaemonClient;
let tempRepo: { path: string; cleanup: () => Promise<void> };
let workspaceId: string;
test.beforeAll(async () => {
tempRepo = await createTempGitRepo("perf-");
client = await connectTerminalClient();
// Seed the workspace in the daemon so the app can resolve the path
const seedResult = await client.openProject(tempRepo.path);
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
workspaceId = seedResult.workspace.id;
});
test.afterAll(async () => {
@@ -45,7 +50,7 @@ test.describe("Terminal wire performance", () => {
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await navigateToTerminal(page, { workspaceId, terminalId });
await setupDeterministicPrompt(page);
const sentinel = `PERF_DONE_${Date.now()}`;
@@ -104,7 +109,7 @@ test.describe("Terminal wire performance", () => {
const terminalId = result.terminal.id;
try {
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
await navigateToTerminal(page, { workspaceId, terminalId });
await setupDeterministicPrompt(page);
// Ensure clean prompt state
+129
View File
@@ -0,0 +1,129 @@
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace cwd correctness", () => {
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-main-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
// Use sidebar navigation to avoid Expo Router hydration issues
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-cwd-worktree-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `workspace-cwd-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
// Use sidebar navigation to avoid Expo Router hydration issues
// with direct URL navigation to the 2nd+ workspace.
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
await terminal.first().click();
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
@@ -0,0 +1,195 @@
import { execSync } from "node:child_process";
import { realpathSync } from "node:fs";
import path from "node:path";
import { expect, test } from "./fixtures";
import { waitForTabBar } from "./helpers/launcher";
import { createTempGitRepo } from "./helpers/workspace";
import {
createAgentChatFromLauncher,
createStandaloneTerminalFromLauncher,
expectTerminalCwd,
} from "./helpers/workspace-lifecycle";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
seedProjectForWorkspaceSetup,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace lifecycle", () => {
// The first test after a spec-file switch can intermittently fail because
// the shared daemon still holds stale sessions from the previous spec.
// One retry is enough for the daemon to stabilize.
test.describe.configure({ retries: 1 });
test.describe("Main checkout", () => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-chat-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(60_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-main-shell-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspaceResult = await client.openProject(repo.path);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, repo.path);
} finally {
await client.close();
await repo.cleanup();
}
});
});
test.describe("Worktree workspace", () => {
test("creates an agent chat via New Chat", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-chat-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-chat-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createAgentChatFromLauncher(page);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
test("creates a terminal with correct CWD", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("lifecycle-wt-shell-");
const resolvedTmp = realpathSync("/tmp");
const worktreePath = path.join(
resolvedTmp,
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
const branchName = `lifecycle-wt-shell-${Date.now()}`;
let worktreeCreated = false;
try {
await seedProjectForWorkspaceSetup(client, repo.path);
execSync(
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
{
cwd: repo.path,
stdio: "ignore",
},
);
worktreeCreated = true;
const workspaceResult = await client.openProject(worktreePath);
if (!workspaceResult.workspace) {
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
}
const workspaceId = String(workspaceResult.workspace.id);
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await createStandaloneTerminalFromLauncher(page);
await expectTerminalCwd(page, worktreePath);
} finally {
if (worktreeCreated) {
try {
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
cwd: repo.path,
stdio: "ignore",
});
} catch {
// Best-effort cleanup so test failures preserve the original error.
}
}
await client.close();
await repo.cleanup();
}
});
});
});
@@ -0,0 +1,164 @@
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app";
import {
archiveAgentFromDaemon,
connectArchiveTabDaemonClient,
createIdleAgent,
expectWorkspaceTabHidden,
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "./helpers/archive-tab";
import {
archiveLocalWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
} from "./helpers/new-workspace";
import { createTempGitRepo } from "./helpers/workspace";
import {
getVisibleWorkspaceAgentTabIds,
expectOnlyWorkspaceAgentTabsVisible,
waitForWorkspaceTabsVisible,
} from "./helpers/workspace-tabs";
import {
expectSidebarWorkspaceSelected,
expectWorkspaceHeader,
switchWorkspaceViaSidebar,
waitForSidebarHydration,
} from "./helpers/workspace-ui";
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
page,
}) => {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
const workspaceClient = await connectNewWorkspaceDaemonClient();
const archiveClient = await connectArchiveTabDaemonClient();
const workspaceIds = new Set<string>();
const agentIds: string[] = [];
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
try {
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
workspaceIds.add(firstWorkspace.workspaceId);
workspaceIds.add(secondWorkspace.workspaceId);
const firstAgent = await createIdleAgent(archiveClient, {
cwd: firstRepo.path,
title: `workspace-nav-a-${Date.now()}`,
});
const secondAgent = await createIdleAgent(archiveClient, {
cwd: secondRepo.path,
title: `workspace-nav-b-${Date.now()}`,
});
agentIds.push(firstAgent.id, secondAgent.id);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
selected: false,
});
await expectWorkspaceHeader(page, {
title: firstWorkspace.workspaceName,
subtitle: firstWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, firstAgent.id);
await expectWorkspaceTabHidden(page, secondAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${firstAgent.id}`,
]);
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: secondWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
selected: false,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`,
]);
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
timeout: 30_000,
});
await expectSidebarWorkspaceSelected({
page,
serverId,
workspaceId: secondWorkspace.workspaceId,
});
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await expectWorkspaceTabVisible(page, secondAgent.id);
await expectWorkspaceTabHidden(page, firstAgent.id);
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
`workspace-tab-agent_${secondAgent.id}`,
]);
} finally {
for (const agentId of agentIds) {
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
}
for (const workspaceId of workspaceIds) {
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
}
await archiveClient.close().catch(() => undefined);
await workspaceClient.close().catch(() => undefined);
await secondRepo.cleanup();
await firstRepo.cleanup();
}
});
});
@@ -0,0 +1,106 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
findWorktreeWorkspaceForProject,
openHomeWithProject,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await waitForTabBar(page);
}
test.describe("Workspace setup runtime authority", () => {
test.describe.configure({ retries: 1 });
test("worktree workspace is created in its own directory", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-setup-chat-");
try {
await client.openProject(repo.path);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-chat-${Date.now()}`,
});
const workspaceId = String(workspace.id);
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
// Navigate to the workspace via sidebar
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
} finally {
await client.close();
await repo.cleanup();
}
});
test("first terminal opens in the created workspace directory", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-setup-terminal-");
try {
await client.openProject(repo.path);
// Create workspace via daemon API since the new workspace screen
// no longer has a standalone terminal button
const worktreeSlug = `setup-terminal-${Date.now()}`;
const result = await client.createPaseoWorktree({
cwd: repo.path,
worktreeSlug,
});
if (!result.workspace || result.error) {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
const workspaceId = String(result.workspace.id);
// Navigate to the worktree workspace via sidebar click (direct URL
// navigation for freshly created worktree workspaces can race with
// Expo Router hydration, so we use the sidebar which is authoritative).
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspaceId);
await clickTerminal(page);
const terminal = page.locator('[data-testid="terminal-surface"]');
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
// Verify terminal is listed under the worktree directory, not the original repo
await expect
.poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, {
timeout: 30_000,
})
.toBe(true);
expect((await client.listTerminals(repo.path)).terminals.length).toBe(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -0,0 +1,345 @@
import { test, expect } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
import { waitForTerminalContent } from "./helpers/terminal-perf";
import {
connectWorkspaceSetupClient,
createWorkspaceThroughDaemon,
expectSetupPanel,
openHomeWithProject,
seedProjectForWorkspaceSetup,
waitForWorkspaceSetupProgress,
} from "./helpers/workspace-setup";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
async function navigateToWorkspaceViaSidebar(
page: import("@playwright/test").Page,
workspaceId: string,
): Promise<void> {
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
const row = page.getByTestId(testId);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
}
test.describe("Workspace setup streaming", () => {
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-open-", {
paseoConfig: {
worktree: {
setup: [
"sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'",
],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-open-${Date.now()}`,
});
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await expectSetupPanel(page);
} finally {
await client.close();
await repo.cleanup();
}
});
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-ui-flow-", {
paseoConfig: {
worktree: {
setup: [
"sh -c 'echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'",
],
},
},
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
// Wait for setup completion via daemon (setup snapshots are per-session,
// so the browser session won't receive progress events).
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-ui-flow-${Date.now()}`,
});
await completed;
// Navigate to workspace and verify it's usable
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
await page.getByTestId("workspace-new-agent-tab").first().click();
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
timeout: 30_000,
});
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
await explorerToggle.click();
}
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
timeout: 30_000,
});
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
} finally {
await client.close();
await repo.cleanup();
}
});
test("streams running and completed setup snapshots for a successful setup", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-success-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const initialRunning = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log === "",
);
const runningWithOutput = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-success",
});
const initialPayload = await initialRunning;
const runningPayload = await runningWithOutput;
const completedPayload = await completed;
expect(initialPayload.detail.log).toBe("");
expect(runningPayload.detail.log).toContain("starting setup");
expect(completedPayload.detail.log).toContain("setup complete");
expect(completedPayload.error).toBeNull();
} finally {
await client.close();
await repo.cleanup();
}
});
test("streams a failed setup snapshot when setup fails", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-failure-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const failed = waitForWorkspaceSetupProgress(
client,
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-failure",
});
const failedPayload = await failed;
expect(failedPayload.detail.log).toContain("starting setup");
expect(failedPayload.detail.log).toContain("setup failed");
expect(failedPayload.error).toMatch(/failed/i);
} finally {
await client.close();
await repo.cleanup();
}
});
test("emits a completed empty snapshot when no setup commands exist", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-none-");
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" &&
payload.detail.commands.length === 0 &&
payload.detail.log === "",
);
await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: "workspace-setup-none",
});
const completedPayload = await completed;
expect(completedPayload.error).toBeNull();
expect(completedPayload.detail.commands).toEqual([]);
expect(completedPayload.detail.log).toBe("");
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches script terminals after setup completes", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-svc-ui-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
scripts: {
web: {
command:
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
// Wait for setup completion via daemon (setup snapshots are per-session)
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const workspace = await createWorkspaceThroughDaemon(client, {
cwd: repo.path,
worktreeSlug: `setup-svc-${Date.now()}`,
});
await completed;
await openHomeWithProject(page, repo.path);
await navigateToWorkspaceViaSidebar(page, workspace.id);
await waitForWorkspaceTabsVisible(page);
// Wait for the script terminal tab to appear in the tabs bar.
// The tab title shows the command, not the script name.
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
// Click the script terminal tab
await terminalTab.click();
// Verify the terminal surface rendered
const terminalSurface = page.getByTestId("terminal-surface").first();
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
// Wait for terminal to fully attach (loading overlay gone)
await page
.locator('[data-testid="terminal-attach-loading"]')
.waitFor({ state: "hidden", timeout: 10_000 })
.catch(() => {
// overlay may never appear if attachment is instant
});
await terminalSurface.click();
// Verify the terminal output contains "listening on" via xterm buffer API.
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
} finally {
await client.close();
await repo.cleanup();
}
});
test("launches workspace scripts after setup completes", async () => {
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("setup-scripts-", {
paseoConfig: {
worktree: {
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
},
scripts: {
editor: {
command: "npm run dev",
},
},
},
});
try {
await seedProjectForWorkspaceSetup(client, repo.path);
const completed = waitForWorkspaceSetupProgress(
client,
(payload) =>
payload.status === "completed" && payload.detail.log.includes("setup complete"),
);
const result = await client.createPaseoWorktree({
cwd: repo.path,
worktreeSlug: "workspace-setup-scripts",
});
if (!result.workspace) {
throw new Error(result.error ?? "Failed to create workspace");
}
const workspaceDir = result.workspace.workspaceDirectory;
await completed;
await expect
.poll(async () => {
const terminals = await client.listTerminals(workspaceDir);
return terminals.terminals.find((terminal) => terminal.name === "editor") ?? null;
})
.toMatchObject({
id: expect.any(String),
name: "editor",
});
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -0,0 +1,40 @@
# NOTE: Maestro requires a config section for every flow file, even when used
# via `runFlow`. Keep this aligned with the dev build application id.
appId: sh.paseo
---
- launchApp:
clearState: true
- runFlow: launch.yaml
- extendedWaitUntil:
visible:
id: "welcome-screen"
timeout: 30000
- tapOn:
id: "welcome-direct-connection"
- extendedWaitUntil:
visible:
id: "add-host-modal"
timeout: 10000
- tapOn:
id: "direct-host-input"
- eraseText
- inputText: "127.0.0.1:6767"
- assertVisible:
id: "direct-host-input"
text: "127.0.0.1:6767"
- tapOn:
id: "direct-host-submit"
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 60000
- assertVisible:
id: "message-input-attach-button"
@@ -0,0 +1,50 @@
appId: sh.paseo
---
- runFlow: flows/land-in-chat.yaml
- tapOn:
id: "message-input-attach-button"
- extendedWaitUntil:
visible:
id: "message-input-attachment-menu"
timeout: 10000
- tapOn:
id: "message-input-attachment-menu-item-image"
- runFlow:
when:
visible: "Allow Full Access"
commands:
- tapOn: "Allow Full Access"
- runFlow:
when:
visible: "Allow Access to All Photos"
commands:
- tapOn: "Allow Access to All Photos"
- extendedWaitUntil:
visible: "Cancel"
timeout: 30000
- tapOn: "Cancel"
- extendedWaitUntil:
visible:
id: "message-input-root"
timeout: 10000
# If the dropdown Modal left an invisible backdrop mounted behind the picker,
# this tap is swallowed and the attachment menu never reopens.
- tapOn:
id: "message-input-attach-button"
- extendedWaitUntil:
visible:
id: "message-input-attachment-menu"
timeout: 10000
- tapOn:
id: "message-input-attachment-menu-backdrop"
+9 -1
View File
@@ -8,7 +8,15 @@ appId: sh.paseo
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn: "Pair"
# Prove the input received the URL intact. If this fails, iOS keyboard input
# dropped characters and the controlled input path needs a source fix.
- assertVisible:
id: "pair-link-input"
text: ${PAIRING_OFFER_URL}
- tapOn:
id: "pair-link-submit"
- extendedWaitUntil:
visible: "New agent"
+1 -1
View File
@@ -8,7 +8,7 @@ appId: sh.paseo
- eraseText
- inputText: ${PAIRING_OFFER_URL}
- tapOn:
point: "75%,36%"
id: "pair-link-submit"
- extendedWaitUntil:
visible: "New agent"
+2 -2
View File
@@ -81,8 +81,8 @@
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^0.546.0",
"mnemonic-id": "^3.2.7",
"react": "19.1.4",
"react-dom": "19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "^0.81.5",
"react-native-css": "^3.0.1",
"react-native-draggable-flatlist": "^4.0.3",
+16 -4
View File
@@ -62,7 +62,9 @@ import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout
import { CommandCenter } from "@/components/command-center";
import { ProjectPickerModal } from "@/components/project-picker-modal";
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
import { queryClient } from "@/query/query-client";
import {
WEB_NOTIFICATION_CLICK_EVENT,
@@ -79,6 +81,7 @@ import {
parseServerIdFromPathname,
parseHostAgentRouteFromPathname,
parseWorkspaceOpenIntent,
decodeWorkspaceIdFromPathSegment,
} from "@/utils/host-routes";
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
import { isWeb, isNative } from "@/constants/platform";
@@ -401,6 +404,12 @@ function AppContainer({
const isCompactLayout = useIsCompactFormFactor();
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const pathname = usePathname();
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
// global concerns like keyboard shortcuts. Split those out so settings (and
// other non-workspace routes) don't need a special-case to keep shortcuts alive.
const keyboardShortcutsEnabled = chromeEnabled || pathname.startsWith("/settings");
useEffect(() => {
const bp = UnistylesRuntime.breakpoint;
@@ -432,16 +441,17 @@ function AppContainer({
}, [isCompactLayout, chromeEnabled, isFocusModeEnabled, agentListOpen, sidebarWidth]);
useKeyboardShortcuts({
enabled: chromeEnabled,
enabled: keyboardShortcutsEnabled,
isMobile: isCompactLayout,
toggleAgentList,
selectedAgentId,
toggleFileExplorer,
toggleBothSidebars,
toggleFocusMode,
cycleTheme,
});
useActiveWorktreeNewAction();
const containerStyle = useMemo(
() => ({ flex: 1 as const, backgroundColor: theme.colors.surface0 }),
[theme.colors.surface0],
@@ -460,6 +470,7 @@ function AppContainer({
<UpdateBanner />
<CommandCenter />
<ProjectPickerModal />
<WorkspaceSetupDialog />
<KeyboardShortcutsDialog />
</View>
);
@@ -788,7 +799,6 @@ function FaviconStatusSync() {
function RootStack() {
const storeReady = useStoreReady();
const { theme } = useUnistyles();
return (
<Stack
screenOptions={{
@@ -801,7 +811,8 @@ function RootStack() {
>
<Stack.Protected guard={storeReady}>
<Stack.Screen name="welcome" />
<Stack.Screen name="settings" />
<Stack.Screen name="settings/index" />
<Stack.Screen name="settings/[section]" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen
@@ -818,6 +829,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]/open-project" />
<Stack.Screen name="h/[serverId]/settings" />
<Stack.Screen name="index" />
<Stack.Screen name="settings/hosts/[serverId]" />
</Stack>
);
}
@@ -4,6 +4,7 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -31,6 +32,21 @@ function HostAgentReadyRouteContent() {
}
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
});
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
const hasHydratedWorkspaces = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
);
const resolvedWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return resolveWorkspaceIdByExecutionDirectory({
workspaces: state.sessions[serverId]?.workspaces?.values(),
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
});
});
useEffect(() => {
if (redirectedRef.current) {
@@ -42,18 +58,17 @@ function HostAgentReadyRouteContent() {
return;
}
const normalizedCwd = agentCwd?.trim();
if (normalizedCwd) {
if (resolvedWorkspaceId) {
redirectedRef.current = true;
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: normalizedCwd,
workspaceId: resolvedWorkspaceId,
target: { kind: "agent", agentId },
}) as any,
);
}
}, [agentCwd, agentId, router, serverId]);
}, [agentId, resolvedWorkspaceId, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -62,14 +77,14 @@ function HostAgentReadyRouteContent() {
if (!serverId || !agentId) {
return;
}
if (agentCwd?.trim()) {
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
return;
}
if (!client || !isConnected) {
redirectedRef.current = true;
router.replace(buildHostRootRoute(serverId));
}
}, [agentCwd, agentId, client, isConnected, router, serverId]);
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
useEffect(() => {
if (redirectedRef.current) {
@@ -87,12 +102,19 @@ function HostAgentReadyRouteContent() {
return;
}
const cwd = result?.agent?.cwd?.trim();
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: sessionWorkspaces?.values(),
workspaceDirectory: cwd,
});
if (!workspaceId && !hasHydratedWorkspaces) {
return;
}
redirectedRef.current = true;
if (cwd) {
if (workspaceId) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: cwd,
workspaceId,
target: { kind: "agent", agentId },
}) as any,
);
@@ -111,7 +133,7 @@ function HostAgentReadyRouteContent() {
return () => {
cancelled = true;
};
}, [agentId, client, isConnected, router, serverId]);
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
return null;
}
+4 -81
View File
@@ -1,86 +1,9 @@
import { useEffect } from "react";
import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { useSessionStore } from "@/stores/session-store";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import {
buildHostOpenProjectRoute,
buildHostRootRoute,
buildHostWorkspaceRoute,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
import { Redirect, useLocalSearchParams } from "expo-router";
import { buildHostOpenProjectRoute } from "@/utils/host-routes";
export default function HostIndexRoute() {
return (
<HostRouteBootstrapBoundary>
<HostIndexRouteContent />
</HostRouteBootstrapBoundary>
);
}
function HostIndexRouteContent() {
const router = useRouter();
const pathname = usePathname();
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const { isLoading: preferencesLoading } = useFormPreferences();
const sessionAgents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
);
const sessionWorkspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
useEffect(() => {
if (preferencesLoading) {
return;
}
if (!serverId) {
return;
}
const rootRoute = buildHostRootRoute(serverId);
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
return;
}
const timer = setTimeout(() => {
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
return;
}
const visibleAgents = sessionAgents
? Array.from(sessionAgents.values()).filter((agent) => !agent.archivedAt)
: [];
visibleAgents.sort(
(left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime(),
);
const visibleWorkspaces = sessionWorkspaces ? Array.from(sessionWorkspaces.values()) : [];
const primaryAgent = visibleAgents[0];
if (primaryAgent?.cwd?.trim()) {
router.replace(
prepareWorkspaceTab({
serverId,
workspaceId: primaryAgent.cwd.trim(),
target: { kind: "agent", agentId: primaryAgent.id },
}) as any,
);
return;
}
const primaryWorkspace = visibleWorkspaces[0];
if (primaryWorkspace?.id?.trim()) {
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()));
return;
}
router.replace(buildHostOpenProjectRoute(serverId));
}, HOST_ROOT_REDIRECT_DELAY_MS);
return () => clearTimeout(timer);
}, [pathname, preferencesLoading, router, serverId, sessionAgents, sessionWorkspaces]);
return null;
if (!serverId) return null;
return <Redirect href={buildHostOpenProjectRoute(serverId)} />;
}
+21
View File
@@ -0,0 +1,21 @@
import { useLocalSearchParams } from "expo-router";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function HostNewWorkspaceRoute() {
const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
const displayName = typeof params.name === "string" ? params.name : undefined;
if (!sourceDirectory) {
return null;
}
return (
<NewWorkspaceScreen
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
/>
);
}
@@ -1,10 +1,15 @@
import { Redirect, useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
import { buildSettingsHostRoute, buildSettingsRoute } from "@/utils/host-routes";
export default function LegacyHostSettingsRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
const href = serverId.length > 0 ? buildSettingsHostRoute(serverId) : buildSettingsRoute();
export default function HostSettingsRoute() {
return (
<HostRouteBootstrapBoundary>
<SettingsScreen />
<Redirect href={href} />
</HostRouteBootstrapBoundary>
);
}
@@ -1,9 +1,16 @@
import { useEffect, useRef, useState } from "react";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useNavigation } from "@react-navigation/native";
import {
useGlobalSearchParams,
useLocalSearchParams,
useRouter,
useRootNavigationState,
} from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
@@ -32,9 +39,35 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
if (openIntent.kind === "file") {
return { kind: "file", path: openIntent.path };
}
if (openIntent.kind === "setup") {
return { kind: "setup", workspaceId: openIntent.workspaceId };
}
return { kind: "draft", draftId: openIntent.draftId };
}
function stripOpenSearchParamFromBrowserUrl() {
if (!isWeb || typeof window === "undefined") {
return;
}
const url = new URL(window.location.href);
if (!url.searchParams.has("open")) {
return;
}
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
function clearConsumedOpenIntent(input: {
navigation: { setParams: (...args: any[]) => void };
router: { replace: (...args: any[]) => void };
serverId: string;
workspaceId: string;
}) {
input.router.replace(buildHostWorkspaceRoute(input.serverId, input.workspaceId));
input.navigation.setParams({ open: undefined });
stripOpenSearchParamFromBrowserUrl();
}
export default function HostWorkspaceLayout() {
return (
<HostRouteBootstrapBoundary>
@@ -44,6 +77,8 @@ export default function HostWorkspaceLayout() {
}
function HostWorkspaceLayoutContent() {
const navigation = useNavigation();
const router = useRouter();
const rootNavigationState = useRootNavigationState();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
@@ -71,6 +106,13 @@ function HostWorkspaceLayoutContent() {
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) {
clearConsumedOpenIntent({
navigation,
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
return;
}
consumedIntentRef.current = consumptionKey;
@@ -88,16 +130,15 @@ function HostWorkspaceLayoutContent() {
// Expo Router's replace ignores query-param-only changes (findDivergentState
// skips search params). Strip ?open from the browser URL directly so the
// address bar reflects the clean workspace route.
if (isWeb && typeof window !== "undefined") {
const url = new URL(window.location.href);
if (url.searchParams.has("open")) {
url.searchParams.delete("open");
window.history.replaceState(null, "", url.toString());
}
}
clearConsumedOpenIntent({
navigation,
router,
serverId,
workspaceId,
});
setIntentConsumed(true);
}, [openValue, rootNavigationState?.key, serverId, workspaceId]);
}, [navigation, openValue, rootNavigationState?.key, router, serverId, workspaceId]);
if (openValue && !intentConsumed) {
return null;
+4 -1
View File
@@ -4,6 +4,7 @@ import { StartupSplashScreen } from "@/screens/startup-splash-screen";
import { useHostRuntimeBootstrapState, useStoreReady } from "@/app/_layout";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
const WELCOME_ROUTE = "/welcome";
@@ -32,6 +33,8 @@ function useAnyOnlineHostServerId(serverIds: string[]): string | null {
);
}
const isDesktop = shouldUseDesktopDaemon();
export default function Index() {
const router = useRouter();
const pathname = usePathname();
@@ -52,5 +55,5 @@ export default function Index() {
router.replace(targetRoute);
}, [anyOnlineServerId, pathname, router, storeReady]);
return <StartupSplashScreen bootstrapState={bootstrapState} />;
return <StartupSplashScreen bootstrapState={isDesktop ? bootstrapState : undefined} />;
}
+11 -77
View File
@@ -5,35 +5,19 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult } from "expo-camera";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { buildHostRootRoute, buildHostSettingsRoute } from "@/utils/host-routes";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
backgroundColor: theme.colors.surface0,
},
header: {
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[4],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
},
headerTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
},
headerButtonText: {
color: theme.colors.palette.blue[400],
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
},
body: {
flex: 1,
paddingHorizontal: theme.spacing[6],
@@ -140,63 +124,32 @@ export default function PairScanScreen() {
const router = useRouter();
const params = useLocalSearchParams<{
source?: string;
sourceServerId?: string;
targetServerId?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const sourceServerId = typeof params.sourceServerId === "string" ? params.sourceServerId : null;
const targetServerId = typeof params.targetServerId === "string" ? params.targetServerId : null;
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const [permission, requestPermission] = useCameraPermissions();
const [isPairing, setIsPairing] = useState(false);
const lastScannedRef = useRef<string | null>(null);
const returnToSource = useCallback(
const navigateToPairedHost = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
return;
}
if (source === "editHost" && targetServerId) {
const settingsServerId = sourceServerId ?? targetServerId;
router.replace({
pathname: buildHostSettingsRoute(settingsServerId),
params: { editHost: targetServerId },
} as any);
return;
}
// settings (default): return to previous screen
try {
router.back();
} catch {
const settingsServerId = sourceServerId ?? serverId;
router.replace(buildHostSettingsRoute(settingsServerId));
}
router.replace(buildSettingsHostRoute(serverId));
},
[router, source, sourceServerId, targetServerId],
[router, source],
);
const closeToSource = useCallback(() => {
if (source === "editHost" && targetServerId) {
const settingsServerId = sourceServerId ?? targetServerId;
router.replace({
pathname: buildHostSettingsRoute(settingsServerId),
params: { editHost: targetServerId },
} as any);
return;
}
try {
router.back();
} catch {
if (sourceServerId) {
router.replace(buildHostSettingsRoute(sourceServerId));
return;
}
router.replace("/" as any);
}
}, [router, source, sourceServerId, targetServerId]);
}, [router]);
useEffect(() => {
if (isWeb) return;
@@ -220,15 +173,6 @@ export default function PairScanScreen() {
const offerPayload = decodeOfferFragmentPayload(encoded);
const offer = ConnectionOfferSchema.parse(offerPayload);
if (targetServerId && offer.serverId !== targetServerId) {
lastScannedRef.current = null;
Alert.alert(
"Wrong daemon",
`That QR code belongs to ${offer.serverId}, not ${targetServerId}.`,
);
return;
}
const { client, hostname } = await connectToDaemon(
{
id: "probe",
@@ -242,7 +186,7 @@ export default function PairScanScreen() {
const profile = await upsertDaemonFromOfferUrl(offerUrl, hostname ?? undefined);
returnToSource(profile.serverId);
navigateToPairedHost(profile.serverId);
} catch (error) {
lastScannedRef.current = null;
const message = error instanceof Error ? error.message : "Unable to pair host";
@@ -251,18 +195,13 @@ export default function PairScanScreen() {
setIsPairing(false);
}
},
[daemons, isPairing, returnToSource, targetServerId, upsertDaemonFromOfferUrl],
[isPairing, navigateToPairedHost, upsertDaemonFromOfferUrl],
);
if (isWeb) {
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={() => router.back()}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<BackHeader title="Scan QR" onBack={() => router.back()} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>Not available on web</Text>
@@ -282,12 +221,7 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<View style={[styles.header, { paddingTop: insets.top + theme.spacing[2] }]}>
<Text style={styles.headerTitle}>Scan QR</Text>
<Pressable onPress={closeToSource}>
<Text style={styles.headerButtonText}>Close</Text>
</Pressable>
</View>
<BackHeader title="Scan QR" onBack={closeToSource} />
<View style={[styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }]}>
{!granted ? (
-27
View File
@@ -1,27 +0,0 @@
import { useEffect, useMemo } from "react";
import { useRouter } from "expo-router";
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
import { useHosts } from "@/runtime/host-runtime";
import { buildHostSettingsRoute } from "@/utils/host-routes";
export default function LegacySettingsRoute() {
const router = useRouter();
const daemons = useHosts();
const targetServerId = useMemo(() => {
return daemons[0]?.serverId ?? null;
}, [daemons]);
useEffect(() => {
if (!targetServerId) {
return;
}
router.replace(buildHostSettingsRoute(targetServerId));
}, [router, targetServerId]);
if (!targetServerId) {
return <DraftAgentScreen />;
}
return null;
}
@@ -0,0 +1,11 @@
import { useLocalSearchParams } from "expo-router";
import SettingsScreen from "@/screens/settings-screen";
import { isSettingsSectionSlug, type SettingsSectionSlug } from "@/utils/host-routes";
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
return <SettingsScreen view={{ kind: "section", section }} />;
}
@@ -0,0 +1,14 @@
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function SettingsHostRoute() {
const params = useLocalSearchParams<{ serverId?: string }>();
const serverId = typeof params.serverId === "string" ? params.serverId.trim() : "";
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={{ kind: "host", serverId }} />
</HostRouteBootstrapBoundary>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Redirect } from "expo-router";
import { useIsCompactFormFactor } from "@/constants/layout";
import SettingsScreen from "@/screens/settings-screen";
import { buildSettingsSectionRoute } from "@/utils/host-routes";
export default function SettingsIndexRoute() {
const isCompactLayout = useIsCompactFormFactor();
if (!isCompactLayout) {
return <Redirect href={buildSettingsSectionRoute("general")} />;
}
return <SettingsScreen view={{ kind: "root" }} />;
}
+7
View File
@@ -1,3 +1,5 @@
import type { GitHubSearchItem } from "@server/shared/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
export interface AttachmentMetadata {
@@ -15,6 +17,11 @@ export interface AttachmentMetadata {
createdAt: number;
}
export type ComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem };
export type AttachmentDataSource =
| { kind: "blob"; blob: Blob }
| { kind: "data_url"; dataUrl: string }
@@ -14,6 +14,8 @@ import {
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import { X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import { isWeb } from "@/constants/platform";
type EscHandler = () => void;
@@ -65,20 +67,24 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.surface2,
overflow: "hidden",
},
header: {
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "center",
alignItems: "flex-start",
justifyContent: "space-between",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
headerTitleGroup: {
flex: 1,
gap: theme.spacing[2],
minWidth: 0,
},
title: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
},
@@ -103,18 +109,16 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[4],
flexGrow: 1,
},
bottomSheetHandle: {
backgroundColor: theme.colors.surface2,
},
bottomSheetHeader: {
paddingHorizontal: theme.spacing[6],
paddingTop: theme.spacing[4],
paddingBottom: theme.spacing[3],
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
alignItems: "flex-start",
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
gap: theme.spacing[3],
},
bottomSheetContent: {
padding: theme.spacing[6],
@@ -152,6 +156,8 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
export interface AdaptiveModalSheetProps {
title: string;
/** Optional content rendered below the title in the header area. */
subtitle?: ReactNode;
visible: boolean;
onClose: () => void;
children: ReactNode;
@@ -159,11 +165,16 @@ export interface AdaptiveModalSheetProps {
snapPoints?: string[];
stackBehavior?: "push" | "switch" | "replace";
testID?: string;
/** Override the max width of the desktop card. */
desktopMaxWidth?: number;
/** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void;
scrollable?: boolean;
}
export function AdaptiveModalSheet({
title,
subtitle,
visible,
onClose,
children,
@@ -171,6 +182,8 @@ export function AdaptiveModalSheet({
snapPoints,
stackBehavior,
testID,
desktopMaxWidth,
onFilesDropped,
scrollable = true,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
@@ -227,14 +240,18 @@ export function AdaptiveModalSheet({
enablePanDownToClose
stackBehavior={stackBehavior}
backgroundComponent={SheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
handleIndicatorStyle={{ backgroundColor: theme.colors.surface2 }}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
accessible={false}
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<View style={styles.bottomSheetHeader} testID={testID}>
<View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
@@ -255,6 +272,35 @@ export function AdaptiveModalSheet({
);
}
const cardInner = (
<>
<View style={styles.header}>
<View style={styles.headerTitleGroup}>
<Text style={[styles.title, { color: theme.colors.foreground }]} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
)}
</>
);
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
@@ -262,27 +308,11 @@ export function AdaptiveModalSheet({
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
{scrollable ? (
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
<View style={[styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }]}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
) : (
<View style={styles.desktopStaticContent}>{children}</View>
cardInner
)}
</View>
</View>
@@ -70,7 +70,9 @@ export function AddHostMethodModal({
<Pressable
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
accessibilityLabel="Direct connection"
testID="add-host-method-direct"
>
<Link2 size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
@@ -80,7 +82,12 @@ export function AddHostMethodModal({
</Pressable>
{isNative ? (
<Pressable style={styles.option} onPress={handleScan} accessibilityLabel="Scan QR code">
<Pressable
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
accessibilityLabel="Scan QR code"
>
<QrCode size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
<Text style={styles.optionText}>Scan QR code</Text>
@@ -92,7 +99,9 @@ export function AddHostMethodModal({
<Pressable
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
accessibilityLabel="Paste pairing link"
testID="add-host-method-pair-link"
>
<ClipboardPaste size={18} color={theme.colors.foreground} />
<View style={styles.optionBody}>
+20 -35
View File
@@ -132,7 +132,6 @@ function buildConnectionFailureCopy(
export interface AddHostModalProps {
visible: boolean;
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
@@ -142,42 +141,41 @@ export interface AddHostModalProps {
}) => void;
}
export function AddHostModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: AddHostModalProps) {
export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertDirectConnection } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const hostInputRef = useRef<TextInput>(null);
const endpointRawRef = useRef("");
const [endpointRaw, setEndpointRaw] = useState("");
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => {
endpointRawRef.current = "";
hostInputRef.current?.clear();
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
}, [isSaving, clearInput, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setEndpointRaw("");
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = endpointRaw.trim();
const raw = endpointRawRef.current.trim();
if (!raw) {
setErrorMessage("Host is required");
return;
@@ -206,15 +204,6 @@ export function AddHostModal({
endpoint,
});
await client.close().catch(() => undefined);
if (targetServerId && serverId !== targetServerId) {
const message = `That endpoint belongs to ${serverId}, not ${targetServerId}.`;
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Wrong daemon", message);
}
return;
}
const isNewHost = !daemons.some((daemon) => daemon.serverId === serverId);
const profile = await upsertDirectConnection({
serverId,
@@ -240,16 +229,7 @@ export function AddHostModal({
} finally {
setIsSaving(false);
}
}, [
daemons,
endpointRaw,
handleClose,
isMobile,
isSaving,
onSaved,
targetServerId,
upsertDirectConnection,
]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDirectConnection]);
return (
<AdaptiveModalSheet
@@ -264,8 +244,12 @@ export function AddHostModal({
<Text style={styles.label}>Host</Text>
<AdaptiveTextInput
ref={hostInputRef}
value={endpointRaw}
onChangeText={setEndpointRaw}
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel="direct-host-input"
onChangeText={(next) => {
endpointRawRef.current = next;
}}
placeholder="hostname:port"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -289,6 +273,7 @@ export function AddHostModal({
onPress={() => void handleSave()}
disabled={isSaving}
leftIcon={<Link2 size={16} color={theme.colors.palette.white} />}
testID="direct-host-submit"
>
{isSaving ? "Connecting..." : "Connect"}
</Button>
@@ -22,7 +22,6 @@ import {
ShieldAlert,
ShieldOff,
} from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
AgentModelDefinition,
@@ -86,6 +85,8 @@ export function DropdownField({
renderTrigger,
testID,
}: DropdownFieldProps): ReactElement {
const { theme } = useUnistyles();
if (renderTrigger) {
return (
<>
@@ -115,7 +116,7 @@ export function DropdownField({
<Text style={value ? styles.dropdownValue : styles.dropdownPlaceholder} numberOfLines={1}>
{value || placeholder}
</Text>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
@@ -151,6 +152,8 @@ export function SelectField({
valueEllipsizeMode,
testID,
}: SelectFieldProps): ReactElement {
const { theme } = useUnistyles();
const getWebKey = useCallback((event: unknown): string | null => {
if (!event || typeof event !== "object") return null;
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
@@ -210,7 +213,7 @@ export function SelectField({
{value || placeholder || "Select..."}
</Text>
</View>
<ChevronRight size={defaultTheme.iconSize.lg} color={defaultTheme.colors.foregroundMuted} />
<ChevronRight size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
@@ -229,7 +232,21 @@ interface DropdownSheetProps {
}
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
const { theme } = useUnistyles();
return (
<Animated.View
pointerEvents="none"
style={[
style,
{
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
]}
/>
);
}
export function DropdownSheet({
@@ -238,6 +255,7 @@ export function DropdownSheet({
onClose,
children,
}: DropdownSheetProps): ReactElement {
const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
@@ -280,12 +298,12 @@ export function DropdownSheet({
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={DropdownSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
<Text style={[styles.dropdownSheetTitle, { color: theme.colors.foreground }]}>{title}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close sheet"
@@ -293,7 +311,7 @@ export function DropdownSheet({
hitSlop={10}
testID="dropdown-sheet-close"
>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
@@ -413,6 +431,8 @@ export function FormSelectTrigger({
valueEllipsizeMode,
testID,
}: CompactSelectFieldProps): ReactElement {
const { theme } = useUnistyles();
const getWebKey = useCallback((event: unknown): string | null => {
if (!event || typeof event !== "object") return null;
const eventWithNative = event as { nativeEvent?: unknown; key?: unknown };
@@ -469,7 +489,7 @@ export function FormSelectTrigger({
<View style={styles.compactSelectValueContainer}>
{showLabel ? <Text style={styles.compactSelectLabel}>{label}</Text> : null}
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<Text
style={hasConcreteValue ? styles.compactSelectValue : styles.compactSelectPlaceholder}
@@ -480,7 +500,7 @@ export function FormSelectTrigger({
</Text>
)}
</View>
<ChevronDown size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
);
}
@@ -518,6 +538,8 @@ export function AgentConfigRow({
onSelectThinkingOption,
disabled,
}: AgentConfigRowProps): ReactElement {
const { theme } = useUnistyles();
const providerOptions: ComboSelectOption[] = useMemo(
() =>
providerDefinitions.map((def) => ({
@@ -576,7 +598,7 @@ export function AgentConfigRow({
placeholder={providerOptions.length > 0 ? "Select..." : "No providers available"}
disabled={disabled || providerOptions.length === 0}
onSelect={onSelectProvider}
icon={<Bot size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={<Bot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-provider-select"
/>
@@ -591,9 +613,7 @@ export function AgentConfigRow({
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
testID="draft-model-select"
/>
@@ -607,7 +627,7 @@ export function AgentConfigRow({
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
icon={<ModeIcon size={theme.iconSize.md} color={modeIconColor} />}
showLabel={false}
testID="draft-mode-select"
/>
@@ -622,9 +642,7 @@ export function AgentConfigRow({
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={
<Brain size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
}
icon={<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
@@ -969,6 +987,8 @@ export function GitOptionsSection({
attachWorktreeError,
onSelectWorktreePath,
}: GitOptionsSectionProps): ReactElement {
const { theme } = useUnistyles();
const isLoading = status === "loading";
const isCreateMode = worktreeMode === "create";
const isAttachMode = worktreeMode === "attach";
@@ -1103,7 +1123,7 @@ export function GitOptionsSection({
autoCapitalize="none"
autoCorrect={false}
placeholder="branch name"
placeholderTextColor={defaultTheme.colors.foregroundMuted}
placeholderTextColor={theme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable
@@ -1111,19 +1131,16 @@ export function GitOptionsSection({
hitSlop={8}
style={styles.baseBranchIconButton}
>
<Check
size={defaultTheme.iconSize.md}
color={defaultTheme.colors.palette.green[500]}
/>
<Check size={theme.iconSize.md} color={theme.colors.palette.green[500]} />
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />
<X size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
) : (
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
<Pencil size={defaultTheme.iconSize.sm} color={defaultTheme.colors.foregroundMuted} />
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
)}
</View>
@@ -1171,14 +1188,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
bottomSheetBackground: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
},
bottomSheetHeader: {
flexDirection: "row",
alignItems: "center",
@@ -24,19 +24,20 @@ describe("submitAgentInput", () => {
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " hello world ",
attachments: [],
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setAttachments,
setSendError,
setIsProcessing,
});
@@ -44,10 +45,55 @@ describe("submitAgentInput", () => {
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "hello world",
imageAttachments: undefined,
attachments: [],
});
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setAttachments).toHaveBeenCalledWith([]);
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
deferred.resolve();
await expect(submitPromise).resolves.toBe("submitted");
expect(clearDraft).toHaveBeenCalledWith("sent");
});
it("preserves the composer before an in-flight submit resolves when requested", async () => {
const deferred = createDeferredPromise<void>();
const attachments = [{ id: "img-1" }];
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {
await deferred.promise;
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const submitPromise = submitAgentInput({
message: " keep me ",
attachments,
submitBehavior: "preserve-and-lock",
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setAttachments,
setSendError,
setIsProcessing,
});
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "keep me",
attachments,
});
expect(setUserInput).not.toHaveBeenCalled();
expect(setAttachments).not.toHaveBeenCalled();
expect(setSendError).toHaveBeenCalledWith(null);
expect(setIsProcessing).toHaveBeenCalledWith(true);
expect(clearDraft).not.toHaveBeenCalled();
@@ -63,21 +109,21 @@ describe("submitAgentInput", () => {
const submitMessage = vi.fn();
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
await expect(
submitAgentInput({
message: " queued message ",
imageAttachments: [{ id: "img-1" }],
attachments: [{ id: "img-1" }],
isAgentRunning: true,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setAttachments,
setSendError,
setIsProcessing,
}),
@@ -85,11 +131,11 @@ describe("submitAgentInput", () => {
expect(queueMessage).toHaveBeenCalledWith({
message: "queued message",
imageAttachments: [{ id: "img-1" }],
attachments: [{ id: "img-1" }],
});
expect(submitMessage).not.toHaveBeenCalled();
expect(setUserInput).toHaveBeenCalledWith("");
expect(setSelectedImages).toHaveBeenCalledWith([]);
expect(setAttachments).toHaveBeenCalledWith([]);
expect(setSendError).not.toHaveBeenCalled();
expect(setIsProcessing).not.toHaveBeenCalled();
expect(clearDraft).not.toHaveBeenCalled();
@@ -103,23 +149,23 @@ describe("submitAgentInput", () => {
});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setSelectedImages = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
const onSubmitError = vi.fn();
const imageAttachments = [{ id: "img-1" }];
const attachments = [{ id: "img-1" }];
await expect(
submitAgentInput({
message: " hello world ",
imageAttachments,
attachments,
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setSelectedImages,
setAttachments,
setSendError,
setIsProcessing,
onSubmitError,
@@ -129,12 +175,46 @@ describe("submitAgentInput", () => {
expect(onSubmitError).toHaveBeenCalledWith(submitError);
expect(setUserInput).toHaveBeenNthCalledWith(1, "");
expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world");
expect(setSelectedImages).toHaveBeenNthCalledWith(1, []);
expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments);
expect(setAttachments).toHaveBeenNthCalledWith(1, []);
expect(setAttachments).toHaveBeenNthCalledWith(2, attachments);
expect(setSendError).toHaveBeenNthCalledWith(1, null);
expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected");
expect(setIsProcessing).toHaveBeenNthCalledWith(1, true);
expect(setIsProcessing).toHaveBeenNthCalledWith(2, false);
expect(clearDraft).not.toHaveBeenCalled();
});
it("submits when empty submit is explicitly allowed", async () => {
const queueMessage = vi.fn();
const submitMessage = vi.fn(async () => {});
const clearDraft = vi.fn();
const setUserInput = vi.fn();
const setAttachments = vi.fn();
const setSendError = vi.fn();
const setIsProcessing = vi.fn();
await expect(
submitAgentInput({
message: " ",
attachments: [],
allowEmptySubmit: true,
isAgentRunning: false,
canSubmit: true,
queueMessage,
submitMessage,
clearDraft,
setUserInput,
setAttachments,
setSendError,
setIsProcessing,
}),
).resolves.toBe("submitted");
expect(queueMessage).not.toHaveBeenCalled();
expect(submitMessage).toHaveBeenCalledWith({
message: "",
attachments: [],
});
expect(clearDraft).toHaveBeenCalledWith("sent");
});
});
@@ -1,28 +1,37 @@
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TImage> {
export interface AgentInputSubmitActionInput<TAttachment> {
message: string;
imageAttachments?: TImage[];
attachments: TAttachment[];
hasExternalContent?: boolean;
allowEmptySubmit?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
forceSend?: boolean;
isAgentRunning: boolean;
canSubmit: boolean;
queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void;
submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise<void>;
queueMessage: (input: { message: string; attachments: TAttachment[] }) => void;
submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise<void>;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
setUserInput: (text: string) => void;
setSelectedImages: (images: TImage[]) => void;
setAttachments: (attachments: TAttachment[]) => void;
setSendError: (message: string | null) => void;
setIsProcessing: (isProcessing: boolean) => void;
onSubmitError?: (error: unknown) => void;
}
export async function submitAgentInput<TImage>(
input: AgentInputSubmitActionInput<TImage>,
export async function submitAgentInput<TAttachment>(
input: AgentInputSubmitActionInput<TAttachment>,
): Promise<AgentInputSubmitResult> {
const trimmedMessage = input.message.trim();
const imageAttachments = input.imageAttachments;
const attachments = input.attachments;
const shouldClearOnSubmit = input.submitBehavior !== "preserve-and-lock";
if (!trimmedMessage && !imageAttachments?.length) {
if (
!trimmedMessage &&
attachments.length === 0 &&
!input.hasExternalContent &&
!input.allowEmptySubmit
) {
return "noop";
}
@@ -31,27 +40,33 @@ export async function submitAgentInput<TImage>(
}
if (input.isAgentRunning && !input.forceSend) {
input.queueMessage({ message: trimmedMessage, imageAttachments });
input.setUserInput("");
input.setSelectedImages([]);
input.queueMessage({ message: trimmedMessage, attachments });
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);
}
return "queued";
}
// Clear immediately so optimistic stream updates and composer state stay in sync.
input.setUserInput("");
input.setSelectedImages([]);
if (shouldClearOnSubmit) {
input.setUserInput("");
input.setAttachments([]);
}
input.setSendError(null);
input.setIsProcessing(true);
try {
await input.submitMessage({ message: trimmedMessage, imageAttachments });
await input.submitMessage({ message: trimmedMessage, attachments });
input.clearDraft("sent");
input.setIsProcessing(false);
return "submitted";
} catch (error) {
input.onSubmitError?.(error);
input.setUserInput(trimmedMessage);
input.setSelectedImages(imageAttachments ?? []);
if (shouldClearOnSubmit) {
input.setUserInput(trimmedMessage);
input.setAttachments(attachments);
}
input.setSendError(error instanceof Error ? error.message : "Failed to send message");
input.setIsProcessing(false);
return "failed";
+37 -3
View File
@@ -17,6 +17,9 @@ import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
@@ -131,6 +134,7 @@ function SessionRow({
const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(agent.status);
const projectPath = shortenPath(agent.cwd);
const ProviderIcon = getProviderIcon(agent.provider);
return (
<Pressable
@@ -146,6 +150,9 @@ function SessionRow({
>
<View style={styles.rowContent}>
<View style={styles.rowTitleRow}>
<View style={styles.providerIconWrap}>
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
numberOfLines={1}
@@ -232,12 +239,21 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
workspaceDirectory: agent.cwd,
});
onAgentSelect?.();
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
return;
}
const route = prepareWorkspaceTab({
serverId,
workspaceId: agent.cwd,
workspaceId,
target: { kind: "agent", agentId },
pin: Boolean(agent.archivedAt),
});
@@ -247,7 +263,18 @@ export function AgentList({
);
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
setActionAgent(agent);
const isRunning = agent.status === "running" || agent.status === "initializing";
if (isRunning) {
setActionAgent(agent);
return;
}
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
if (!client) {
setActionAgent(agent);
return;
}
void client.archiveAgent(agent.id);
}, []);
const handleCloseActionSheet = useCallback(() => {
@@ -351,7 +378,9 @@ export function AgentList({
>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>
{isActionDaemonUnavailable ? "Host offline" : "Archive this session?"}
{isActionDaemonUnavailable
? "Host offline"
: "This agent is still running. Archiving it will stop the agent."}
</Text>
<View style={styles.sheetButtonRow}>
<Pressable
@@ -435,6 +464,11 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
gap: theme.spacing[2],
},
providerIconWrap: {
width: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
},
rowMetaRow: {
flexDirection: "row",
alignItems: "center",
@@ -424,12 +424,7 @@ function ControlledStatusBar({
{thinkingOptions && thinkingOptions.length > 0 ? (
<>
<Tooltip
key={`thinking-${openSelector === "thinking" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={thinkingAnchorRef}
@@ -470,12 +465,7 @@ function ControlledStatusBar({
{modeOptions && modeOptions.length > 0 ? (
<>
<Tooltip
key={`mode-${openSelector === "mode" ? "open" : "closed"}`}
delayDuration={0}
enabledOnDesktop
enabledOnMobile={false}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={modeAnchorRef}
@@ -877,9 +867,8 @@ export const AgentStatusBar = memo(function AgentStatusBar({
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
invalidate: invalidateSnapshot,
} = useProvidersSnapshot(serverId);
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, agent?.cwd);
const snapshotModels = useMemo(() => {
if (!snapshotEntries || !agent?.provider) {
@@ -1044,8 +1033,8 @@ export const AgentStatusBar = memo(function AgentStatusBar({
console.warn("[AgentStatusBar] setAgentFeature failed", error);
});
}}
isModelLoading={snapshotIsLoading || snapshotIsFetching}
onModelSelectorOpen={invalidateSnapshot}
isModelLoading={snapshotIsLoading}
onModelSelectorOpen={refetchSnapshotIfStale}
onDropdownClose={onDropdownClose}
disabled={!client}
/>
@@ -8,8 +8,8 @@ import {
import {
orderHeadForStreamRenderStrategy,
orderTailForStreamRenderStrategy,
resolveStreamRenderStrategy,
} from "./stream-strategy";
import { resolveStreamRenderStrategy } from "./stream-strategy-resolver";
export type StreamRenderSegments = {
historyVirtualized: StreamItem[];
@@ -1,2 +1,3 @@
export * from "./stream-strategy";
export * from "./stream-strategy-resolver";
export * from "./agent-stream-render-model";
@@ -66,6 +66,7 @@ import {
} from "./use-bottom-anchor-controller";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
@@ -136,10 +137,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
workspaceDirectory: workspaceRoot,
});
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId,
workspaceId: workspaceId ?? undefined,
workspaceRoot,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
@@ -179,12 +183,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route);
if (workspaceId) {
const route = prepareWorkspaceTab({
serverId: resolvedServerId,
workspaceId,
target: { kind: "file", path: normalized.file },
});
router.navigate(route);
}
return;
}
@@ -0,0 +1,223 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata } from "@/attachments/types";
import { AttachmentLightbox } from "./attachment-lightbox";
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
colors: {
surface1: "#111",
surface2: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
borderAccent: "#444",
},
};
const imageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "img-1",
fileName: "img-1.png",
byteSize: 42,
createdAt: 1,
};
return {
theme,
imageMetadata,
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
() => "blob:preview",
),
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
X: createIcon("X"),
};
});
vi.mock("expo-image", () => ({
Image: (props: Record<string, unknown>) => {
const source = props.source as { uri?: string } | string | undefined;
const uri = typeof source === "string" ? source : source?.uri;
return React.createElement("div", {
"data-testid": props.testID,
"data-source": uri,
"data-style": JSON.stringify(props.style ?? null),
role: "img",
});
},
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
const Modal = ({
visible = true,
children,
}: {
visible?: boolean;
children?: React.ReactNode;
}) =>
visible ? React.createElement("div", { "data-testid": "lightbox-modal" }, children) : null;
return { ...actual, Modal };
});
vi.mock("@/attachments/use-attachment-preview-url", () => ({
useAttachmentPreviewUrl: (metadata: AttachmentMetadata | null) =>
useAttachmentPreviewUrlMock(metadata),
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function render(element: React.ReactElement) {
act(() => {
root?.render(element);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
describe("AttachmentLightbox", () => {
it("renders nothing when metadata is null", () => {
render(<AttachmentLightbox metadata={null} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull();
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
});
it("renders the image when metadata is provided", () => {
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
const image = queryByTestId("attachment-lightbox-image");
expect(image).not.toBeNull();
expect(image?.getAttribute("data-source")).toBe("blob:preview");
});
it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => {
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
const image = queryByTestId("attachment-lightbox-image");
const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as {
position?: string;
top?: number;
left?: number;
right?: number;
bottom?: number;
} | null;
expect(style?.position).toBe("absolute");
expect(style?.top).toBe(0);
expect(style?.left).toBe(0);
expect(style?.right).toBe(0);
expect(style?.bottom).toBe(0);
});
it("calls onClose when the backdrop is pressed", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
const backdrop = queryByTestId("attachment-lightbox-backdrop");
expect(backdrop).not.toBeNull();
click(backdrop!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls onClose when the close button is pressed", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
const closeButton = document.querySelector(
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
);
expect(closeButton).not.toBeNull();
click(closeButton!);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows error text when the preview URL resolves to null", () => {
useAttachmentPreviewUrlMock.mockReturnValue(null);
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
expect(document.body.textContent ?? "").toContain("Couldn't load image");
});
it("closes on Escape key on web", () => {
const onClose = vi.fn();
render(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
act(() => {
window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,151 @@
import { useEffect, useState } from "react";
import { Modal, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Image as ExpoImage } from "expo-image";
import { X } from "lucide-react-native";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { isWeb } from "@/constants/platform";
interface AttachmentLightboxProps {
metadata: AttachmentMetadata | null;
onClose: () => void;
}
export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) {
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const url = useAttachmentPreviewUrl(metadata);
const [errored, setErrored] = useState(false);
useEffect(() => {
setErrored(false);
}, [metadata?.id]);
useEffect(() => {
if (!isWeb || !metadata) return;
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
window.addEventListener("keydown", handleKeydown);
return () => {
window.removeEventListener("keydown", handleKeydown);
};
}, [metadata, onClose]);
if (!metadata) {
return null;
}
const hasError = errored || !url;
return (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<View style={styles.root}>
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
accessibilityLabel="Dismiss image"
onPress={onClose}
style={styles.backdrop}
/>
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>Couldn't load image</Text>
) : (
<Pressable onPress={() => {}} style={styles.imagePressable}>
<ExpoImage
testID="attachment-lightbox-image"
source={{ uri: url }}
contentFit="contain"
onError={() => setErrored(true)}
style={imageFillStyle}
/>
</Pressable>
)}
</View>
<Pressable
testID="attachment-lightbox-close"
accessibilityRole="button"
accessibilityLabel="Close image"
hitSlop={8}
onPress={onClose}
style={[
styles.closeButton,
{
top: insets.top + theme.spacing[3],
right: insets.right + theme.spacing[3],
},
]}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
</Modal>
);
}
const imageFillStyle = {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
} as const;
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
},
backdrop: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.9)",
},
contentLayer: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: "box-none",
},
imageArea: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: theme.spacing[4],
pointerEvents: "box-none",
},
imagePressable: {
flex: 1,
width: "100%",
alignSelf: "center",
maxWidth: 960,
maxHeight: 640,
},
errorText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
closeButton: {
position: "absolute",
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
}));
@@ -0,0 +1,91 @@
import { type ReactNode, useState } from "react";
import { Pressable, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
interface AttachmentPillProps {
onOpen: () => void;
onRemove: () => void;
openAccessibilityLabel: string;
removeAccessibilityLabel: string;
disabled?: boolean;
testID?: string;
children: ReactNode;
}
export function AttachmentPill({
onOpen,
onRemove,
openAccessibilityLabel,
removeAccessibilityLabel,
disabled = false,
testID,
children,
}: AttachmentPillProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isBodyHovered, setIsBodyHovered] = useState(false);
const [isCloseHovered, setIsCloseHovered] = useState(false);
const alwaysShow = isNative || isCompact;
const showRemove = alwaysShow || isBodyHovered || isCloseHovered;
return (
<View style={styles.wrapper}>
<Pressable
testID={testID}
onPress={onOpen}
disabled={disabled}
onHoverIn={() => setIsBodyHovered(true)}
onHoverOut={() => setIsBodyHovered(false)}
accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel}
style={styles.body}
>
{children}
</Pressable>
<Pressable
onPress={onRemove}
disabled={disabled}
onHoverIn={() => setIsCloseHovered(true)}
onHoverOut={() => setIsCloseHovered(false)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={removeAccessibilityLabel}
style={[styles.closeButton, !showRemove && styles.closeButtonHidden]}
>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
wrapper: {
position: "relative",
},
body: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
closeButton: {
position: "absolute",
top: -8,
left: -8,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
zIndex: 1,
},
closeButtonHidden: {
opacity: 0,
pointerEvents: "none",
},
}));
@@ -1,5 +1,5 @@
import { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Pressable, View } from "react-native";
import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, GitBranch } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -8,6 +8,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useBranchSwitcher } from "@/hooks/use-branch-switcher";
import { ScreenTitle } from "@/components/headers/screen-title";
interface BranchSwitcherProps {
currentBranchName: string | null;
@@ -46,9 +47,7 @@ export function BranchSwitcher({
const titleContent = (
<>
{isGitCheckout ? <GitBranch size={14} color={theme.colors.foregroundMuted} /> : null}
<Text testID="workspace-header-title" style={styles.headerTitle} numberOfLines={1}>
{title}
</Text>
<ScreenTitle testID="workspace-header-title">{title}</ScreenTitle>
</>
);
@@ -102,15 +101,6 @@ export function BranchSwitcher({
}
const styles = StyleSheet.create((theme) => ({
headerTitle: {
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
flexShrink: 1,
},
branchSwitcherTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -67,8 +67,8 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(rows[1]!, "gpt-5.4")).toBe(true);
});
it("builds an explicit trigger label for the selected provider and model", () => {
it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("Codex", "GPT-5.4")).toBe("GPT-5.4");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
});
});
@@ -533,10 +533,22 @@ export function CombinedModelSelector({
return { kind: "provider", providerId, providerLabel: label };
}, [allProviderModels, providerDefinitions]);
const computeInitialView = useCallback((): SelectorView => {
if (singleProviderView) return singleProviderView;
const selectedFavoriteKey = `${selectedProvider}:${selectedModel}`;
if (selectedProvider && selectedModel && !favoriteKeys.has(selectedFavoriteKey)) {
const label = resolveProviderLabel(providerDefinitions, selectedProvider);
return { kind: "provider", providerId: selectedProvider, providerLabel: label };
}
return { kind: "all" };
}, [singleProviderView, selectedProvider, selectedModel, favoriteKeys, providerDefinitions]);
const handleOpenChange = useCallback(
(open: boolean) => {
setIsOpen(open);
setView(singleProviderView ?? { kind: "all" });
setView(computeInitialView());
if (open) {
onOpen?.();
} else {
@@ -544,26 +556,24 @@ export function CombinedModelSelector({
onClose?.();
}
},
[onOpen, onClose, singleProviderView],
[onOpen, onClose, computeInitialView],
);
const handleSelect = useCallback(
(provider: string, modelId: string) => {
onSelect(provider as AgentProvider, modelId);
setIsOpen(false);
setView(singleProviderView ?? { kind: "all" });
setSearchQuery("");
},
[onSelect, singleProviderView],
[onSelect],
);
const ProviderIcon = getProviderIcon(selectedProvider);
const selectedProviderLabel = useMemo(
() => resolveProviderLabel(providerDefinitions, selectedProvider),
[providerDefinitions, selectedProvider],
);
const selectedModelLabel = useMemo(() => {
if (!selectedModel) {
return isLoading ? "Loading..." : "Select model";
}
const models = allProviderModels.get(selectedProvider);
if (!models) {
return isLoading ? "Loading..." : "Select model";
@@ -586,8 +596,8 @@ export function CombinedModelSelector({
return selectedModelLabel;
}
return buildSelectedTriggerLabel(selectedProviderLabel, selectedModelLabel);
}, [selectedModelLabel, selectedProviderLabel]);
return buildSelectedTriggerLabel(selectedModelLabel);
}, [selectedModelLabel]);
useEffect(() => {
if (platformIsWeb) {
@@ -788,11 +798,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
level2Header: {
backgroundColor: theme.colors.surface1,
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
level2Header: {},
backButton: {
flexDirection: "row",
alignItems: "center",
@@ -13,7 +13,7 @@ export function resolveProviderLabel(
);
}
export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string {
export function buildSelectedTriggerLabel(modelLabel: string): string {
return modelLabel;
}
@@ -0,0 +1,30 @@
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { AgentAttachment } from "@server/shared/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
export type ImageAttachment = AttachmentMetadata;
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
images: ImageAttachment[];
attachments: AgentAttachment[];
} {
const images: ImageAttachment[] = [];
const reviewAttachments: AgentAttachment[] = [];
for (const attachment of attachments) {
if (attachment.kind === "image") {
images.push(attachment.metadata);
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
if (reviewAttachment) {
reviewAttachments.push(reviewAttachment);
}
}
return {
images,
attachments: reviewAttachments,
};
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
import { resolveStatusControlMode } from "./composer.status-controls";
describe("resolveStatusControlMode", () => {
it("uses ready mode when no controlled status controls are provided", () => {
@@ -0,0 +1,925 @@
import React, { useState } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import type { GitHubSearchItem } from "@server/shared/messages";
import { Composer } from "./composer";
import { splitComposerAttachmentsForSubmit } from "./composer-attachments";
const {
theme,
imageMetadata,
issueItem,
prItem,
mockClient,
pickImagesMock,
persistAttachmentFromBlobMock,
deleteAttachmentsMock,
encodeImagesMock,
openExternalUrlMock,
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
} = vi.hoisted(() => {
const theme = {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15, lg: 18 },
fontWeight: { normal: "400", medium: "500" },
shadow: { md: {} },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#888",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
border: "#555",
borderAccent: "#444",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff453a",
palette: {
green: { 500: "#30d158", 600: "#24b14c", 800: "#126024" },
red: { 500: "#ff453a", 600: "#d92d20" },
zinc: { 600: "#52525b" },
},
},
};
const imageMetadata: AttachmentMetadata = {
id: "img-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "img-1",
fileName: "img-1.png",
byteSize: 42,
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
url: "https://github.com/acme/paseo/issues/101",
state: "open",
body: "Issue body",
labels: ["composer"],
baseRefName: null,
headRefName: null,
};
const prItem: GitHubSearchItem = {
kind: "pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
state: "open",
body: "PR body",
labels: ["composer"],
baseRefName: "main",
headRefName: "composer-attachments",
};
const mockClient = {
isConnected: true,
searchGitHub: vi.fn(async () => ({ items: [issueItem, prItem] })),
sendAgentMessage: vi.fn(async () => {}),
cancelAgent: vi.fn(async () => {}),
};
const setQueuedMessagesMock = vi.fn();
const mockSessionState: {
sessions: Record<
string,
{
agents: Map<string, { status: string; lastUsage: null }>;
queuedMessages: Map<string, unknown[]>;
agentStreamHead: Map<string, unknown[]>;
agentStreamTail: Map<string, unknown[]>;
}
>;
setQueuedMessages: ReturnType<typeof vi.fn>;
setAgentStreamTail?: ReturnType<typeof vi.fn>;
setAgentStreamHead?: ReturnType<typeof vi.fn>;
} = {
sessions: {
server: {
agents: new Map([["agent", { status: "idle", lastUsage: null }]]),
queuedMessages: new Map(),
agentStreamHead: new Map(),
agentStreamTail: new Map(),
},
},
setQueuedMessages: setQueuedMessagesMock,
};
const setAgentStreamTailMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = mockSessionState.sessions[serverId];
session.agentStreamTail = updater(session.agentStreamTail);
},
);
const setAgentStreamHeadMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
const session = mockSessionState.sessions[serverId];
session.agentStreamHead = updater(session.agentStreamHead);
},
);
mockSessionState.setAgentStreamTail = setAgentStreamTailMock;
mockSessionState.setAgentStreamHead = setAgentStreamHeadMock;
return {
theme,
imageMetadata,
issueItem,
prItem,
mockClient,
pickImagesMock: vi.fn(),
persistAttachmentFromBlobMock: vi.fn(async () => imageMetadata),
deleteAttachmentsMock: vi.fn(async () => {}),
encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images),
openExternalUrlMock: vi.fn(async () => {}),
mockSessionState,
setAgentStreamTailMock,
setAgentStreamHeadMock,
setQueuedMessagesMock,
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
FOOTER_HEIGHT: 72,
MAX_CONTENT_WIDTH: 900,
useIsCompactFormFactor: () => false,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ArrowUp: createIcon("ArrowUp"),
CornerDownLeft: createIcon("CornerDownLeft"),
Square: createIcon("Square"),
Pencil: createIcon("Pencil"),
AudioLines: createIcon("AudioLines"),
CircleDot: createIcon("CircleDot"),
GitPullRequest: createIcon("GitPullRequest"),
X: createIcon("X"),
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
Plus: createIcon("Plus"),
Paperclip: createIcon("Paperclip"),
Github: createIcon("Github"),
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Keyframe: class Keyframe {
duration() {
return this;
}
withCallback() {
return this;
}
},
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useSharedValue: (value: unknown) => ({ value }),
useAnimatedStyle: (factory: () => unknown) => factory(),
withTiming: (value: unknown) => value,
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}));
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockClient,
useHostRuntimeIsConnected: () => true,
useHostRuntimeAgentDirectoryStatus: () => "ready",
}));
vi.mock("@/stores/session-store", () => {
const useSessionStore = (selector: (state: typeof mockSessionState) => unknown) =>
selector(mockSessionState);
useSessionStore.getState = () => mockSessionState;
return { useSessionStore };
});
vi.mock("@/hooks/use-image-attachment-picker", () => ({
useImageAttachmentPicker: () => ({ pickImages: pickImagesMock }),
}));
vi.mock("@/attachments/service", () => ({
persistAttachmentFromBlob: persistAttachmentFromBlobMock,
persistAttachmentFromFileUri: vi.fn(async () => imageMetadata),
deleteAttachments: deleteAttachmentsMock,
}));
vi.mock("@/attachments/use-attachment-preview-url", () => ({
useAttachmentPreviewUrl: () => "blob:preview",
}));
vi.mock("expo-image", () => ({
Image: (props: Record<string, unknown>) => {
const source = props.source as { uri?: string } | string | undefined;
const uri = typeof source === "string" ? source : source?.uri;
return React.createElement("div", {
"data-testid": props.testID,
"data-source": uri,
role: "img",
});
},
}));
vi.mock("react-native", async () => {
const actual = await vi.importActual<Record<string, unknown>>("react-native");
const Modal = ({
visible = true,
children,
}: {
visible?: boolean;
children?: React.ReactNode;
}) =>
visible ? React.createElement("div", { "data-testid": "lightbox-modal" }, children) : null;
return { ...actual, Modal };
});
vi.mock("@/utils/encode-images", () => ({
encodeImages: encodeImagesMock,
}));
vi.mock("@/utils/open-external-url", () => ({
openExternalUrl: openExternalUrlMock,
}));
vi.mock("@/hooks/use-settings", () => ({
useAppSettings: () => ({ settings: { sendBehavior: "interrupt" } }),
}));
vi.mock("@/hooks/use-agent-autocomplete", () => ({
useAgentAutocomplete: () => ({
isVisible: false,
options: [],
selectedIndex: -1,
isLoading: false,
errorMessage: null,
loadingText: "",
emptyText: "",
onSelectOption: vi.fn(),
onKeyPress: () => false,
}),
}));
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/hooks/use-keyboard-action-handler", () => ({
useKeyboardActionHandler: () => {},
}));
vi.mock("@/hooks/use-keyboard-shift-style", () => ({
useKeyboardShiftStyle: () => ({ style: {} }),
}));
vi.mock("@/contexts/voice-context", () => ({
useVoiceOptional: () => null,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/scroll-jank-investigation", () => ({
markScrollInvestigationRender: vi.fn(),
markScrollInvestigationEvent: vi.fn(),
}));
vi.mock("@/components/agent-status-bar", () => ({
AgentStatusBar: () => null,
DraftAgentStatusBar: () => null,
}));
vi.mock("@/components/context-window-meter", () => ({
ContextWindowMeter: () => null,
}));
vi.mock("@/components/composer.status-controls", () => ({
resolveStatusControlMode: () => "agent",
}));
vi.mock("@/components/ui/autocomplete", () => ({
Autocomplete: () => null,
}));
vi.mock("@/components/use-web-scrollbar", () => ({
useWebElementScrollbar: () => null,
}));
vi.mock("@/hooks/use-web-scrollbar-style", () => ({
useWebScrollbarStyle: () => undefined,
}));
vi.mock("@/hooks/use-dictation", () => ({
useDictation: () => ({
isRecording: false,
isProcessing: false,
partialTranscript: "",
volume: 0,
duration: 0,
error: null,
status: "idle",
startDictation: vi.fn(),
cancelDictation: vi.fn(),
confirmDictation: vi.fn(),
retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(),
}),
}));
vi.mock("@/utils/server-info-capabilities", () => ({
resolveVoiceUnavailableMessage: () => null,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({
asChild,
children,
onPress,
accessibilityLabel,
disabled,
}: {
asChild?: boolean;
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
onPress?: () => void;
accessibilityLabel?: string;
disabled?: boolean;
}) =>
asChild ? (
<>{children}</>
) : (
<button type="button" aria-label={accessibilityLabel} disabled={disabled} onClick={onPress}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/dropdown-menu", () => {
const DropdownContext = React.createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = React.useState(false);
return (
<DropdownContext.Provider value={{ open, setOpen }}>{children}</DropdownContext.Provider>
);
},
DropdownMenuTrigger: ({
children,
testID,
accessibilityLabel,
disabled,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
disabled?: boolean;
}) => {
const menu = React.useContext(DropdownContext);
return (
<button
type="button"
data-testid={testID}
aria-label={accessibilityLabel}
disabled={disabled}
onClick={() => menu?.setOpen(true)}
>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: menu?.open ?? false })
: children}
</button>
);
},
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => {
const menu = React.useContext(DropdownContext);
return menu?.open ? <div data-testid={testID}>{children}</div> : null;
},
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
};
});
vi.mock("@/components/ui/combobox", () => ({
Combobox: ({
open,
options,
renderOption,
anchorRef,
}: {
open?: boolean;
options: Array<{ id: string; label: string; description?: string }>;
renderOption?: (input: {
option: { id: string; label: string; description?: string };
selected: boolean;
active: boolean;
onPress: () => void;
}) => React.ReactElement;
anchorRef: React.RefObject<unknown>;
}) =>
open ? (
<div
data-testid="composer-github-combobox"
data-anchor={anchorRef.current ? "attached" : "missing"}
>
{options.map((option) =>
renderOption ? (
renderOption({ option, selected: false, active: false, onPress: vi.fn() })
) : (
<button type="button" key={option.id}>
{option.label}
</button>
),
)}
</div>
) : null,
ComboboxItem: ({
label,
selected,
onPress,
testID,
}: {
label: string;
selected?: boolean;
onPress: () => void;
testID?: string;
}) => (
<button
type="button"
data-testid={testID}
data-selected={selected ? "true" : "false"}
onClick={onPress}
>
{label}
</button>
),
}));
vi.mock("./dictation-controls", () => ({
DictationOverlay: () => null,
}));
vi.mock("./realtime-voice-overlay", () => ({
RealtimeVoiceOverlay: () => null,
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
let queryClient: QueryClient | null = null;
let latestAttachments: ComposerAttachment[] = [];
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
vi.stubGlobal("Blob", dom.window.Blob);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
latestAttachments = [];
mockClient.searchGitHub.mockClear();
mockClient.sendAgentMessage.mockClear();
mockClient.cancelAgent.mockClear();
pickImagesMock.mockReset();
persistAttachmentFromBlobMock.mockClear();
deleteAttachmentsMock.mockClear();
encodeImagesMock.mockClear();
openExternalUrlMock.mockClear();
setAgentStreamTailMock.mockClear();
setAgentStreamHeadMock.mockClear();
setQueuedMessagesMock.mockClear();
mockSessionState.sessions.server.agentStreamHead = new Map();
mockSessionState.sessions.server.agentStreamTail = new Map();
mockSessionState.sessions.server.queuedMessages = new Map();
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
queryClient?.clear();
root = null;
container = null;
queryClient = null;
vi.unstubAllGlobals();
});
function imageAttachment(id: string): AttachmentMetadata {
return {
...imageMetadata,
id,
storageKey: id,
fileName: `${id}.png`,
};
}
function ComposerHarness({
initialText = "",
initialAttachments = [],
isSubmitLoading = false,
submitBehavior,
}: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
}) {
const [text, setText] = useState(initialText);
const [attachments, setAttachments] = useState(initialAttachments);
latestAttachments = attachments;
return (
<QueryClientProvider client={queryClient!}>
<Composer
agentId="agent"
serverId="server"
isPaneFocused
value={text}
onChangeText={setText}
attachments={attachments}
onChangeAttachments={(updater) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
return next;
});
}}
isSubmitLoading={isSubmitLoading}
submitBehavior={submitBehavior}
cwd="/repo"
clearDraft={vi.fn()}
/>
</QueryClientProvider>
);
}
function renderComposer(
input: {
initialText?: string;
initialAttachments?: ComposerAttachment[];
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
} = {},
) {
act(() => {
root?.render(<ComposerHarness {...input} />);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
async function flushAsyncWork() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
async function findByTestId(testID: string): Promise<HTMLElement> {
for (let attempt = 0; attempt < 10; attempt += 1) {
await flushAsyncWork();
const element = queryByTestId(testID);
if (element) {
return element;
}
}
throw new Error(`Missing element with testID ${testID}`);
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllAttachmentMenuItems(): NodeListOf<HTMLElement> {
return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]');
}
describe("Composer attachments", () => {
it("opens a Plus menu with image and GitHub attachment actions", () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
expect(queryAllAttachmentMenuItems()).toHaveLength(2);
expect(queryByTestId("message-input-attachment-menu-item-image")?.textContent).toBe(
"Add image",
);
expect(queryByTestId("message-input-attachment-menu-item-github")?.textContent).toBe(
"Add issue or PR",
);
});
it("adds a picked image as a unified composer attachment and renders a pill", async () => {
pickImagesMock.mockResolvedValue([
{
source: { kind: "blob", blob: new Blob(["image"]) },
mimeType: "image/png",
fileName: "img-1.png",
},
]);
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-image")!);
await flushAsyncWork();
expect(persistAttachmentFromBlobMock).toHaveBeenCalledWith({
blob: expect.any(Blob),
mimeType: "image/png",
fileName: "img-1.png",
});
expect(latestAttachments).toEqual([{ kind: "image", metadata: imageMetadata }]);
expect(queryByTestId("composer-image-attachment-pill")).not.toBeNull();
});
it("opens the GitHub combobox from the Plus menu anchored at the Plus button", async () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const combobox = await findByTestId("composer-github-combobox");
expect(combobox.dataset.anchor).toBe("attached");
});
it("closes the GitHub combobox after selecting an item", async () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const issueOption = await findByTestId("composer-github-option-issue:101");
click(issueOption);
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(queryByTestId("composer-github-combobox")).toBeNull();
});
it("toggles GitHub search items into and out of unified composer attachments", async () => {
renderComposer();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const issueOption = await findByTestId("composer-github-option-issue:101");
click(issueOption);
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull();
expect(queryByTestId("composer-github-combobox")).toBeNull();
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-github")!);
const selectedIssueOption = await findByTestId("composer-github-option-issue:101");
click(selectedIssueOption);
expect(latestAttachments).toEqual([]);
expect(queryByTestId("composer-github-combobox")).toBeNull();
});
it("submits mixed composer attachments as the expected wire images and attachments", async () => {
const image = imageAttachment("img-2");
renderComposer({
initialText: "send attachments",
initialAttachments: [
{ kind: "image", metadata: image },
{ kind: "github_pr", item: prItem },
],
});
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"send attachments",
expect.objectContaining({
images: [image],
attachments: [
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
],
}),
);
});
it("submits empty wire arrays when there are no composer attachments", async () => {
renderComposer({ initialText: "plain message" });
click(document.querySelector('[aria-label="Send message"]')!);
await flushAsyncWork();
expect(mockClient.sendAgentMessage).toHaveBeenCalledWith(
"agent",
"plain message",
expect.objectContaining({
images: [],
attachments: [],
}),
);
});
it("removes the image attachment when its pill X button is pressed", () => {
const image = imageAttachment("img-remove");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
const removeButton = document.querySelector('[aria-label="Remove image attachment"]');
expect(removeButton).not.toBeNull();
click(removeButton!);
expect(latestAttachments).toEqual([]);
expect(deleteAttachmentsMock).toHaveBeenCalledWith([image]);
});
it("removes a GitHub attachment when its pill X button is pressed", () => {
renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] });
const removeButton = document.querySelector(`[aria-label="Remove issue #${issueItem.number}"]`);
expect(removeButton).not.toBeNull();
click(removeButton!);
expect(latestAttachments).toEqual([]);
});
it("opens the GitHub issue URL when the pill body is pressed", () => {
renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] });
click(queryByTestId("composer-github-attachment-pill")!);
expect(openExternalUrlMock).toHaveBeenCalledWith(issueItem.url);
expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]);
});
it("opens the GitHub PR URL when the pill body is pressed", () => {
renderComposer({ initialAttachments: [{ kind: "github_pr", item: prItem }] });
click(queryByTestId("composer-github-attachment-pill")!);
expect(openExternalUrlMock).toHaveBeenCalledWith(prItem.url);
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("opens the image lightbox when the image pill body is pressed", () => {
const image = imageAttachment("img-body");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
expect(openExternalUrlMock).not.toHaveBeenCalled();
expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]);
});
it("locks the preserved draft while submit loading", () => {
renderComposer({
initialText: "keep this prompt",
initialAttachments: [{ kind: "github_pr", item: prItem }],
isSubmitLoading: true,
submitBehavior: "preserve-and-lock",
});
const textInput = document.querySelector('[aria-label="Message agent..."]');
const attachButton = queryByTestId("message-input-attach-button");
const pill = queryByTestId("composer-github-attachment-pill");
const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`);
expect(textInput).toHaveProperty("readOnly", true);
expect(textInput).toHaveProperty("value", "keep this prompt");
expect(attachButton).toHaveProperty("disabled", true);
expect(pill).not.toBeNull();
expect(removeButton).not.toBeNull();
click(pill!);
click(removeButton!);
expect(openExternalUrlMock).not.toHaveBeenCalled();
expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]);
});
it("closes the image lightbox when its close button is pressed", () => {
const image = imageAttachment("img-close");
renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] });
click(queryByTestId("composer-image-attachment-pill")!);
expect(queryByTestId("attachment-lightbox-image")).not.toBeNull();
const closeButton = document.querySelector(
'[aria-label="Close image"][data-testid="attachment-lightbox-close"]',
);
expect(closeButton).not.toBeNull();
click(closeButton!);
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
});
it("splits mixed composer attachments only at the submit wire boundary", () => {
const image = imageAttachment("img-3");
expect(
splitComposerAttachmentsForSubmit([
{ kind: "image", metadata: image },
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
]),
).toEqual({
images: [image],
attachments: [
{
type: "github_issue",
mimeType: "application/github-issue",
number: 101,
title: "Fix composer attachments",
url: "https://github.com/acme/paseo/issues/101",
body: "Issue body",
},
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
],
});
});
});
@@ -1,11 +1,21 @@
import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { useState, useEffect, useRef, useCallback } from "react";
import { View, Pressable, Text, ActivityIndicator, Image } from "react-native";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native";
import {
ArrowUp,
Square,
Pencil,
AudioLines,
CircleDot,
GitPullRequest,
Github,
Paperclip,
} from "lucide-react-native";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQuery } from "@tanstack/react-query";
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
@@ -21,8 +31,9 @@ import {
type MessagePayload,
type ImageAttachment,
type MessageInputRef,
type AttachmentMenuItem,
} from "./message-input";
import { Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images";
import { focusWithRetries } from "@/utils/web-focus";
@@ -43,7 +54,7 @@ import {
persistAttachmentFromBlob,
persistAttachmentFromFileUri,
} from "@/attachments/service";
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
import { resolveStatusControlMode } from "@/components/composer.status-controls";
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
@@ -51,28 +62,55 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat
import { submitAgentInput } from "@/components/agent-input-submit";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@server/shared/messages";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { AttachmentPill } from "@/components/attachment-pill";
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
type QueuedMessage = {
id: string;
text: string;
images?: ImageAttachment[];
attachments: ComposerAttachment[];
};
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
type AttachmentListUpdater =
| ComposerAttachment[]
| ((prev: ComposerAttachment[]) => ComposerAttachment[]);
interface AgentInputAreaProps {
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={styles.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={styles.imageThumbnail} />;
}
interface ComposerProps {
agentId: string;
serverId: string;
isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean;
/** When true, the composer can submit even with no text or attachments. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
submitIcon?: "arrow" | "return";
/** Externally controlled loading state. When true, disables the submit button. */
isSubmitLoading?: boolean;
submitBehavior?: "clear" | "preserve-and-lock";
/** When true, blurs the input immediately when submitting. */
blurOnSubmit?: boolean;
value: string;
onChangeText: (text: string) => void;
images: ImageAttachment[];
onChangeImages: (updater: ImageListUpdater) => void;
attachments: ComposerAttachment[];
onChangeAttachments: (updater: AttachmentListUpdater) => void;
cwd: string;
clearDraft: (lifecycle: "sent" | "abandoned") => void;
/** When true, auto-focuses the text input on web. */
autoFocus?: boolean;
@@ -89,23 +127,31 @@ interface AgentInputAreaProps {
onAttentionPromptSend?: () => void;
/** Controlled status controls rendered in input area (draft flows). */
statusControls?: DraftAgentStatusBarProps;
/** Extra styles merged onto the message input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
}
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
export function AgentInputArea({
export function Composer({
agentId,
serverId,
isPaneFocused,
onSubmitMessage,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
submitIcon = "arrow",
isSubmitLoading = false,
submitBehavior = "clear",
blurOnSubmit = false,
value,
onChangeText,
images,
onChangeImages,
attachments,
onChangeAttachments,
cwd,
clearDraft,
autoFocus = false,
onAddImages,
@@ -116,8 +162,9 @@ export function AgentInputArea({
onAttentionInputFocus,
onAttentionPromptSend,
statusControls,
}: AgentInputAreaProps) {
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
inputWrapperStyle,
}: ComposerProps) {
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
const { theme } = useUnistyles();
const buttonIconSize = isWeb ? theme.iconSize.md : theme.iconSize.lg;
const insets = useSafeAreaInsets();
@@ -163,14 +210,19 @@ export function AgentInputArea({
: MOBILE_MESSAGE_PLACEHOLDER;
const userInput = value;
const setUserInput = onChangeText;
const selectedImages = images;
const setSelectedImages = onChangeImages;
const selectedAttachments = attachments;
const setSelectedAttachments = onChangeAttachments;
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
const [githubSearchQuery, setGithubSearchQuery] = useState("");
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(null);
const isComposerLocked = submitBehavior === "preserve-and-lock" && isSubmitLoading;
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`,
);
@@ -201,16 +253,19 @@ export function AgentInputArea({
const { pickImages } = useImageAttachmentPicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, images?: ImageAttachment[]) => Promise<void>) | null
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
>(null);
const onSubmitMessageRef = useRef(onSubmitMessage);
// Expose addImages function to parent for drag-and-drop support
const addImages = useCallback(
(images: ImageAttachment[]) => {
setSelectedImages((prev) => [...prev, ...images]);
setSelectedAttachments((prev) => [
...prev,
...images.map((metadata) => ({ kind: "image" as const, metadata })),
]);
},
[setSelectedImages],
[setSelectedAttachments],
);
useEffect(() => {
@@ -233,18 +288,18 @@ export function AgentInputArea({
}, [focusInput, onFocusInput]);
const submitMessage = useCallback(
async (text: string, images?: ImageAttachment[]) => {
async (text: string, attachments: ComposerAttachment[]) => {
onMessageSent?.();
if (onSubmitMessageRef.current) {
await onSubmitMessageRef.current({ text, images });
await onSubmitMessageRef.current({ text, attachments, cwd });
return;
}
if (!sendAgentMessageRef.current) {
throw new Error("Host is not connected");
}
await sendAgentMessageRef.current(agentIdRef.current, text, images);
await sendAgentMessageRef.current(agentIdRef.current, text, attachments);
},
[onMessageSent],
[cwd, onMessageSent],
);
useEffect(() => {
@@ -255,19 +310,20 @@ export function AgentInputArea({
sendAgentMessageRef.current = async (
agentId: string,
text: string,
images?: ImageAttachment[],
attachments: ComposerAttachment[],
) => {
if (!client) {
throw new Error("Host is not connected");
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const clientMessageId = generateMessageId();
const userMessage: StreamItem = {
kind: "user_message",
id: clientMessageId,
text,
timestamp: new Date(),
...(images && images.length > 0 ? { images } : {}),
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
};
// Append to head if streaming (keeps the user message with the current
@@ -291,10 +347,11 @@ export function AgentInputArea({
return updated;
});
}
const imagesData = await encodeImages(images);
const imagesData = await encodeImages(wirePayload.images);
await client.sendAgentMessage(agentId, text, {
messageId: clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
images: imagesData ?? [],
attachments: wirePayload.attachments,
});
onAttentionPromptSend?.();
};
@@ -318,14 +375,14 @@ export function AgentInputArea({
[agentId, serverId, setQueuedMessages],
);
function queueMessage(message: string, imageAttachments?: ImageAttachment[]) {
function queueMessage(message: string, attachments: ComposerAttachment[]) {
const trimmedMessage = message.trim();
if (!trimmedMessage && !imageAttachments?.length) return;
if (!trimmedMessage && attachments.length === 0) return;
const newItem = {
id: generateMessageId(),
text: trimmedMessage,
images: imageAttachments,
attachments,
};
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
@@ -335,32 +392,35 @@ export function AgentInputArea({
});
setUserInput("");
setSelectedImages([]);
setSelectedAttachments([]);
}
async function sendMessageWithContent(
message: string,
imageAttachments?: ImageAttachment[],
attachments: ComposerAttachment[],
forceSend?: boolean,
) {
await submitAgentInput({
message,
imageAttachments,
attachments,
hasExternalContent,
allowEmptySubmit,
forceSend,
submitBehavior,
isAgentRunning: agentState.status === "running",
// Parent-managed submits are still valid submit paths even when the
// transport is disconnected, because the parent decides the failure mode.
canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current),
queueMessage: ({ message, imageAttachments }) => {
queueMessage(message, imageAttachments);
queueMessage: ({ message, attachments }) => {
queueMessage(message, attachments);
},
submitMessage: async ({ message, imageAttachments }) => {
await submitMessage(message, imageAttachments);
submitMessage: async ({ message, attachments }) => {
await submitMessage(message, attachments);
},
clearDraft,
setUserInput,
setSelectedImages: (images) => {
setSelectedImages(images);
setAttachments: (nextAttachments) => {
setSelectedAttachments(nextAttachments);
},
setSendError,
setIsProcessing,
@@ -374,10 +434,10 @@ export function AgentInputArea({
if (blurOnSubmit) {
messageInputRef.current?.blur();
}
void sendMessageWithContent(payload.text, payload.images, payload.forceSend);
void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend);
}
async function handlePickImage() {
const handlePickImage = useCallback(async () => {
const result = await pickImages();
if (!result?.length) {
return;
@@ -400,19 +460,27 @@ export function AgentInputArea({
});
}),
);
setSelectedImages((prev) => [...prev, ...newImages]);
}
addImages(newImages);
}, [addImages, pickImages]);
function handleRemoveImage(index: number) {
setSelectedImages((prev) => {
function handleRemoveAttachment(index: number) {
setSelectedAttachments((prev) => {
const removed = prev[index];
if (removed) {
void deleteAttachments([removed]);
if (removed?.kind === "image") {
void deleteAttachments([removed.metadata]);
}
return prev.filter((_, i) => i !== index);
});
}
function handleOpenAttachment(attachment: ComposerAttachment) {
if (attachment.kind === "image") {
setLightboxMetadata(attachment.metadata);
return;
}
void openExternalUrl(attachment.item.url);
}
useEffect(() => {
if (!isAgentRunning || !isConnected) {
setIsCancellingAgent(false);
@@ -510,7 +578,7 @@ export function AgentInputArea({
return;
}
void voice.startVoice(serverId, agentId).catch((error) => {
console.error("[AgentInputArea] Failed to start voice mode", error);
console.error("[Composer] Failed to start voice mode", error);
const message =
error instanceof Error ? error.message : typeof error === "string" ? error : null;
if (message && message.trim().length > 0) {
@@ -525,7 +593,7 @@ export function AgentInputArea({
updateQueue((current) => current.filter((q) => q.id !== id));
setUserInput(item.text);
setSelectedImages(item.images ?? []);
setSelectedAttachments(item.attachments);
}
async function handleSendQueuedNow(id: string) {
@@ -537,7 +605,7 @@ export function AgentInputArea({
// Reuse the regular send path; server-side send atomically interrupts any active run.
try {
await submitMessage(item.text, item.images);
await submitMessage(item.text, item.attachments);
} catch (error) {
updateQueue((current) => [item, ...current]);
setSendError(error instanceof Error ? error.message : "Failed to send message");
@@ -545,10 +613,10 @@ export function AgentInputArea({
}
const handleQueue = useCallback((payload: MessagePayload) => {
queueMessage(payload.text, payload.images);
queueMessage(payload.text, payload.attachments);
}, []);
const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0;
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete and stop action.
const handleCommandKeyPress = useCallback(
@@ -607,40 +675,49 @@ export function AgentInputArea({
</Tooltip>
) : null;
const rightContent = (
<View style={styles.rightControls}>
{!isVoiceModeForAgent && hasAgent ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any,
]}
>
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={buttonIconSize} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
);
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
const rightContent =
showVoiceModeButton || cancelButton ? (
<View style={styles.rightControls}>
{showVoiceModeButton ? (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleToggleRealtimeVoice}
disabled={!isConnected || voice?.isVoiceSwitching}
accessibilityLabel="Enable Voice mode"
accessibilityRole="button"
style={({ hovered }) => [
styles.realtimeVoiceButton as any,
(hovered ? styles.iconButtonHovered : undefined) as any,
(!isConnected || voice?.isVoiceSwitching
? styles.buttonDisabled
: undefined) as any,
]}
>
{({ hovered }) =>
voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Voice mode</Text>
{voiceToggleKeys ? (
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
) : null}
{cancelButton}
</View>
) : null;
const hasContextWindowMeter =
typeof agentState.contextWindowMaxTokens === "number" &&
@@ -659,6 +736,78 @@ export function AgentInputArea({
</View>
);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useQuery({
queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed],
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.searchGitHub({
cwd,
query: githubSearchQueryTrimmed,
limit: 20,
});
},
enabled: isConnected && !!client && cwd.trim().length > 0,
staleTime: 30_000,
});
const githubSearchItems = githubSearchResultsQuery.data?.items ?? [];
const githubSearchOptions: ComboboxOption[] = useMemo(
() =>
githubSearchItems.map((item) => ({
id: `${item.kind}:${item.number}`,
label: `#${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
})),
[githubSearchItems, githubSearchQueryTrimmed],
);
const attachmentMenuItems = useMemo<AttachmentMenuItem[]>(
() => [
{
id: "image",
label: "Add image",
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
void handlePickImage();
},
},
{
id: "github",
label: "Add issue or PR",
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
setIsGithubPickerOpen(true);
},
},
],
[handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md],
);
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
setSelectedAttachments((current) => {
const matches = (attachment: ComposerAttachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
const nextAttachment: ComposerAttachment =
item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
return [...current, nextAttachment];
});
setIsGithubPickerOpen(false);
setGithubSearchQuery("");
},
[setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen],
);
const leftContent =
resolveStatusControlMode(statusControls) === "draft" && statusControls ? (
<DraftAgentStatusBar {...statusControls} />
@@ -670,8 +819,9 @@ export function AgentInputArea({
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */}
<View style={styles.inputAreaContainer}>
<View style={[styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked]}>
<View style={styles.inputAreaContent}>
{/* Queue list */}
{queuedMessages.length > 0 && (
@@ -718,18 +868,80 @@ export function AgentInputArea({
</View>
)}
{selectedAttachments.length > 0 ? (
<View style={styles.attachmentPreviewContainer} testID="composer-attachment-pills">
{selectedAttachments.map((attachment, index) => {
if (attachment.kind === "image") {
return (
<AttachmentPill
key={`${attachment.metadata.id}-${index}`}
testID="composer-image-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
disabled={isComposerLocked}
>
<ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill>
);
}
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
return (
<AttachmentPill
key={`${item.kind}:${item.number}`}
testID="composer-github-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
disabled={isComposerLocked}
>
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
#{item.number} {item.title}
</Text>
</View>
</AttachmentPill>
);
})}
</View>
) : null}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
ref={messageInputRef}
value={userInput}
onChangeText={setUserInput}
onSubmit={handleSubmit}
hasExternalContent={hasExternalContent}
allowEmptySubmit={allowEmptySubmit}
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
submitIcon={submitIcon}
isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading}
images={selectedImages}
onPickImages={handlePickImage}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={(node) => {
attachButtonRef.current = node;
}}
onAddImages={addImages}
onRemoveImage={handleRemoveImage}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
@@ -757,6 +969,61 @@ export function AgentInputArea({
}
}}
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={() => {}}
keepOpenOnSelect
searchable
searchPlaceholder="Search issues and PRs..."
title="Attach issue or PR"
open={isGithubPickerOpen}
onOpenChange={(open) => {
setIsGithubPickerOpen(open);
if (!open) {
setGithubSearchQuery("");
}
}}
onSearchQueryChange={setGithubSearchQuery}
desktopPlacement="top-start"
anchorRef={attachButtonRef}
emptyText={githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."}
renderOption={({ option, active }) => {
const item = githubSearchItems.find((candidate) => {
return `${candidate.kind}:${candidate.number}` === option.id;
});
if (!item) {
return <View key={option.id} />;
}
const selected = selectedAttachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return (
<ComboboxItem
key={option.id}
testID={`composer-github-option-${option.id}`}
label={option.label}
selected={selected}
active={active}
onPress={() => handleToggleGithubItem(item)}
leadingSlot={
item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)
}
/>
);
}}
/>
</View>
</View>
@@ -785,6 +1052,9 @@ const styles = StyleSheet.create(((theme: Theme) => ({
overflow: "visible",
padding: theme.spacing[4],
},
inputAreaLocked: {
opacity: 0.6,
},
inputAreaContent: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
@@ -793,6 +1063,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
messageInputContainer: {
position: "relative",
width: "100%",
gap: theme.spacing[3],
},
autocompletePopover: {
position: "absolute",
@@ -809,11 +1080,12 @@ const styles = StyleSheet.create(((theme: Theme) => ({
backgroundColor: theme.colors.palette.red[600],
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
rightControls: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
gap: theme.spacing[1],
},
contextWindowMeterSlot: {
width: 28,
@@ -835,6 +1107,41 @@ const styles = StyleSheet.create(((theme: Theme) => ({
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
},
attachmentPreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface2,
},
githubPillBody: {
minHeight: 48,
maxWidth: 260,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
backgroundColor: theme.colors.surface1,
},
githubPillIcon: {
width: 18,
alignItems: "center",
justifyContent: "center",
},
githubPillText: {
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -8,7 +8,7 @@ type ContextWindowMeterProps = {
usedTokens: number;
};
const SVG_SIZE = 20;
const SVG_SIZE = 16;
const CENTER = SVG_SIZE / 2;
const RADIUS = 7;
const STROKE_WIDTH = 2.25;
+44
View File
@@ -0,0 +1,44 @@
import { View, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface DiffStatProps {
additions: number;
deletions: number;
}
const compactFormatter = new Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
});
export function formatDiffCount(value: number): string {
return compactFormatter.format(value).toLowerCase();
}
export function DiffStat({ additions, deletions }: DiffStatProps) {
return (
<View style={styles.row}>
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: 4,
flexShrink: 0,
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion,
},
}));
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect, useMemo, useRef, memo, type ReactElement } from "react";
import { useRouter } from "expo-router";
import { DiffStat } from "@/components/diff-stat";
import {
View,
Text,
@@ -488,8 +489,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
)}
</View>
<View style={styles.fileHeaderRight}>
<Text style={styles.additions}>+{file.additions}</Text>
<Text style={styles.deletions}>-{file.deletions}</Text>
<DiffStat additions={file.additions} deletions={file.deletions} />
</View>
</Pressable>
</View>
@@ -1,31 +1,35 @@
import type { ReactNode } from "react";
import { Pressable, Text } from "react-native";
import { Pressable } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
interface BackHeaderProps {
title?: string;
titleAccessory?: ReactNode;
rightContent?: ReactNode;
onBack?: () => void;
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
return (
<ScreenHeader
left={
<>
<Pressable onPress={onBack ?? (() => router.back())} style={styles.backButton}>
<Pressable
onPress={onBack ?? (() => router.back())}
style={styles.backButton}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{title && <ScreenTitle>{title}</ScreenTitle>}
{titleAccessory}
</>
}
right={rightContent}
@@ -45,13 +49,4 @@ const styles = StyleSheet.create((theme) => ({
},
borderRadius: theme.borderRadius.lg,
},
title: {
flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: {
xs: theme.fontWeight.semibold,
md: "400",
},
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,13 @@
import type { ReactNode } from "react";
import { View } from "react-native";
import { headerIconSlotStyle } from "./header-toggle-button";
/**
* Non-interactive icon slot sitting at the start of a screen header's left
* cluster. Shares the same padding + border-radius as `HeaderToggleButton` so
* decorative headers (settings sections, host detail) line up with the sidebar
* toggle across screens.
*/
export function HeaderIconBadge({ children }: { children: ReactNode }) {
return <View style={headerIconSlotStyle.slot}>{children}</View>;
}
@@ -51,7 +51,7 @@ export function HeaderToggleButton({
onPress={(e) => {
onPress(e);
}}
style={[styles.button, style]}
style={[headerIconSlotStyle.slot, style]}
>
{typeof children === "function"
? (state: { pressed: boolean; hovered?: boolean }) =>
@@ -68,14 +68,17 @@ export function HeaderToggleButton({
);
}
const styles = StyleSheet.create((theme) => ({
button: {
export const headerIconSlotStyle = StyleSheet.create((theme) => ({
slot: {
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
const styles = StyleSheet.create((theme) => ({
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -1,8 +1,9 @@
import type { ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -83,11 +84,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
left={
<>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
{title && <ScreenTitle>{title}</ScreenTitle>}
</>
}
right={rightContent}
@@ -101,15 +98,6 @@ const styles = StyleSheet.create((theme) => ({
left: {
gap: theme.spacing[2],
},
title: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { Text, type StyleProp, type TextStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
interface ScreenTitleProps {
children: ReactNode;
numberOfLines?: number;
testID?: string;
style?: StyleProp<TextStyle>;
}
/**
* Canonical screen title for use inside `ScreenHeader`. One typography, one
* color, responsive weight. Leading icons are siblings (HeaderToggleButton,
* HeaderIconBadge) — never nested inside this component.
*/
export function ScreenTitle({ children, numberOfLines = 1, testID, style }: ScreenTitleProps) {
return (
<Text style={[styles.text, style]} numberOfLines={numberOfLines} testID={testID}>
{children}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
text: {
flexShrink: 1,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
md: "300",
},
color: theme.colors.foreground,
},
}));
+25 -104
View File
@@ -33,6 +33,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { router, usePathname } from "expo-router";
import { usePanelStore, MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH } from "@/stores/panel-store";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -46,14 +47,10 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { formatConnectionStatus } from "@/utils/daemons";
import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
useIsCompactFormFactor,
} from "@/constants/layout";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
buildHostSessionsRoute,
buildHostSettingsRoute,
buildSettingsRoute,
mapPathnameToServer,
parseServerIdFromPathname,
} from "@/utils/host-routes";
@@ -220,19 +217,13 @@ export const LeftSidebar = memo(function LeftSidebar({
}, [openProjectPicker]);
const handleSettingsMobile = useCallback(() => {
if (!activeServerId) {
return;
}
closeToAgent();
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId, closeToAgent]);
router.push(buildSettingsRoute());
}, [closeToAgent]);
const handleSettingsDesktop = useCallback(() => {
if (!activeServerId) {
return;
}
router.push(buildHostSettingsRoute(activeServerId));
}, [activeServerId]);
router.push(buildSettingsRoute());
}, []);
const handleViewMoreNavigate = useCallback(() => {
if (!activeServerId) {
@@ -328,44 +319,6 @@ function HostSwitchOption({
);
}
function SessionsButton({ onPress }: { onPress: () => void }) {
const { theme } = useUnistyles();
const pathname = usePathname();
const isActive = pathname.includes("/sessions");
return (
<Pressable
style={({ hovered }) => [
styles.newAgentButton,
hovered && styles.newAgentButtonHovered,
isActive && styles.newAgentButtonActive,
]}
testID="sidebar-sessions"
accessible
accessibilityRole="button"
accessibilityLabel="Sessions"
onPress={onPress}
>
{({ hovered }) => (
<>
<MessagesSquare
size={theme.iconSize.md}
color={hovered || isActive ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<Text
style={[
styles.newAgentButtonText,
(hovered || isActive) && styles.newAgentButtonTextHovered,
]}
>
Sessions
</Text>
</>
)}
</Pressable>
);
}
function MobileSidebar({
theme,
activeServerId,
@@ -394,6 +347,8 @@ function MobileSidebar({
handleViewMoreNavigate,
}: MobileSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const {
translateX,
backdropOpacity,
@@ -544,11 +499,13 @@ function MobileSidebar({
pointerEvents="auto"
>
<View style={styles.sidebarContent} pointerEvents="auto">
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -676,6 +633,8 @@ function DesktopSidebar({
handleViewMore,
}: DesktopSidebarProps) {
const newAgentKeys = useShortcutKeys("new-agent");
const pathname = usePathname();
const isSessionsActive = pathname.includes("/sessions");
const padding = useWindowControlsPadding("sidebar");
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const setSidebarWidth = usePanelStore((state) => state.setSidebarWidth);
@@ -732,11 +691,13 @@ function DesktopSidebar({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={{ height: padding.top }} /> : null}
<View style={styles.sidebarHeader}>
<View style={styles.sidebarHeaderRow}>
<SessionsButton onPress={handleViewMore} />
</View>
</View>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
{isInitialLoad ? (
@@ -881,46 +842,6 @@ const styles = StyleSheet.create((theme) => ({
sidebarDragArea: {
position: "relative",
},
sidebarHeader: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: "none",
},
sidebarHeaderRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
},
newAgentButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
newAgentButtonText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
newAgentButtonTextHovered: {
color: theme.colors.foreground,
},
newAgentButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
newAgentButtonActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
hostTrigger: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,327 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MessageInput, type AttachmentMenuItem } from "./message-input";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 1: 4, 2: 8, 3: 12, 4: 16 },
iconSize: { sm: 14, md: 18, lg: 22 },
borderWidth: { 1: 1 },
borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" },
shadow: { md: {} },
colors: {
surface1: "#111",
surface2: "#222",
surface3: "#333",
surface4: "#888",
foreground: "#fff",
foregroundMuted: "#aaa",
popoverForeground: "#fff",
borderAccent: "#444",
accent: "#0a84ff",
accentForeground: "#fff",
destructive: "#ff453a",
palette: {
green: { 500: "#30d158" },
},
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: false,
isNative: true,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
ArrowUp: createIcon("ArrowUp"),
Mic: createIcon("Mic"),
MicOff: createIcon("MicOff"),
CornerDownLeft: createIcon("CornerDownLeft"),
Plus: createIcon("Plus"),
Square: createIcon("Square"),
};
});
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
Keyframe: class Keyframe {
duration() {
return this;
}
withCallback() {
return this;
}
},
runOnJS: (fn: (...args: unknown[]) => unknown) => fn,
useSharedValue: (value: unknown) => ({ value }),
useAnimatedStyle: (factory: () => unknown) => factory(),
withTiming: (value: unknown) => value,
}));
vi.mock("@/hooks/use-dictation", () => ({
useDictation: () => ({
isRecording: false,
isProcessing: false,
partialTranscript: "",
volume: 0,
duration: 0,
error: null,
status: "idle",
startDictation: vi.fn(),
cancelDictation: vi.fn(),
confirmDictation: vi.fn(),
retryFailedDictation: vi.fn(),
discardFailedDictation: vi.fn(),
}),
}));
vi.mock("@/stores/session-store", () => ({
useSessionStore: (selector: (state: { sessions: Record<string, unknown> }) => unknown) =>
selector({ sessions: {} }),
}));
vi.mock("@/contexts/voice-context", () => ({
useVoiceOptional: () => null,
}));
vi.mock("@/contexts/toast-context", () => ({
useToast: () => ({ error: vi.fn() }),
}));
vi.mock("@/utils/server-info-capabilities", () => ({
resolveVoiceUnavailableMessage: () => null,
}));
vi.mock("@/components/use-web-scrollbar", () => ({
useWebElementScrollbar: () => null,
}));
vi.mock(
"@/hooks/use-web-scrollbar-style",
() => ({
useWebScrollbarStyle: () => undefined,
}),
// @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload.
{ virtual: true },
);
vi.mock("@/hooks/use-shortcut-keys", () => ({
useShortcutKeys: () => null,
}));
vi.mock("@/components/ui/shortcut", () => ({
Shortcut: () => null,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({
asChild,
children,
...props
}: {
asChild?: boolean;
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
} & Record<string, any>) =>
asChild ? (
<>{children}</>
) : (
<button type="button" aria-label={props.accessibilityLabel}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({
children,
testID,
accessibilityLabel,
}: {
children:
| React.ReactNode
| ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode);
testID?: string;
accessibilityLabel?: string;
}) => (
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: false })
: children}
</button>
),
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
}));
vi.mock("./dictation-controls", () => ({
DictationOverlay: () => null,
}));
vi.mock("./realtime-voice-overlay", () => ({
RealtimeVoiceOverlay: () => null,
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
interface RenderMessageInputOptions {
value?: string;
submitIcon?: "arrow" | "return";
}
function renderMessageInput(
menuItems: AttachmentMenuItem[],
{ value = "", submitIcon }: RenderMessageInputOptions = {},
) {
act(() => {
root?.render(
<MessageInput
value={value}
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={menuItems}
client={{ isConnected: true } as never}
isAgentRunning={false}
submitIcon={submitIcon}
onQueue={vi.fn()}
/>,
);
});
}
function click(element: Element) {
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function queryByTestId(testID: string): HTMLElement | null {
return document.querySelector(`[data-testid="${testID}"]`);
}
function queryAllByAriaLabel(label: string): NodeListOf<HTMLElement> {
return document.querySelectorAll(`[aria-label="${label}"]`);
}
describe("MessageInput attachments", () => {
it("renders the Plus attachment button and opens a menu with two attachment items", () => {
const menuItems: AttachmentMenuItem[] = [
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
];
renderMessageInput(menuItems);
expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1);
const attachButton = queryByTestId("message-input-attach-button");
expect(attachButton).not.toBeNull();
click(attachButton!);
expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull();
expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull();
expect(
document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'),
).toHaveLength(2);
});
it("selecting Attach image invokes the supplied image attachment action", () => {
const attachImage = vi.fn();
renderMessageInput([
{ id: "image", label: "Attach image", onSelect: attachImage },
{ id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() },
]);
click(queryByTestId("message-input-attach-button")!);
click(queryByTestId("message-input-attachment-menu-item-image")!);
expect(attachImage).toHaveBeenCalledTimes(1);
});
it("does not render the old queue button", () => {
renderMessageInput([
{ id: "image", label: "Add image", onSelect: vi.fn() },
{ id: "github", label: "Add issue or PR", onSelect: vi.fn() },
]);
expect(queryAllByAriaLabel("Queue message")).toHaveLength(0);
});
it("uses ArrowUp by default and CornerDownLeft when return submit icon is requested", () => {
renderMessageInput([], { value: "Send this" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(0);
renderMessageInput([], { value: "Create this", submitIcon: "return" });
expect(document.querySelectorAll('[data-icon="ArrowUp"]')).toHaveLength(0);
expect(document.querySelectorAll('[data-icon="CornerDownLeft"]')).toHaveLength(1);
});
});
+143 -162
View File
@@ -8,7 +8,6 @@ import {
TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
Image,
BackHandler,
} from "react-native";
import {
@@ -21,7 +20,7 @@ import {
forwardRef,
} from "react";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native";
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "./dictation-controls";
@@ -36,10 +35,16 @@ import {
filesToImageAttachments,
} from "@/utils/image-attachments-from-files";
import type { AttachmentMetadata } from "@/attachments/types";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import type { ComposerAttachment } from "@/attachments/types";
import { focusWithRetries } from "@/utils/web-focus";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useWebElementScrollbar } from "@/components/use-web-scrollbar";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { formatShortcut } from "@/utils/format-shortcut";
@@ -56,21 +61,38 @@ export type ImageAttachment = AttachmentMetadata;
export interface MessagePayload {
text: string;
images?: ImageAttachment[];
attachments: ComposerAttachment[];
cwd: string;
/** When true, bypasses queue and sends immediately even if agent is running */
forceSend?: boolean;
}
export interface AttachmentMenuItem {
id: string;
label: string;
onSelect: () => void;
disabled?: boolean;
icon?: React.ReactElement | null;
}
export interface MessageInputProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean;
/** When true, the submit button stays visible and can submit even with no content. */
allowEmptySubmit?: boolean;
/** Optional accessibility label for the primary submit button. */
submitButtonAccessibilityLabel?: string;
submitIcon?: "arrow" | "return";
isSubmitDisabled?: boolean;
isSubmitLoading?: boolean;
images?: ImageAttachment[];
onPickImages?: () => void;
attachments: ComposerAttachment[];
cwd: string;
attachmentMenuItems: AttachmentMenuItem[];
onAttachButtonRef?: (node: View | null) => void;
onAddImages?: (images: ImageAttachment[]) => void;
onRemoveImage?: (index: number) => void;
client: DaemonClient | null;
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean;
@@ -103,6 +125,8 @@ export interface MessageInputProps {
onSelectionChange?: (selection: { start: number; end: number }) => void;
onFocusChange?: (focused: boolean) => void;
onHeightChange?: (height: number) => void;
/** Extra styles merged onto the input wrapper (e.g. elevated background). */
inputWrapperStyle?: import("react-native").ViewStyle;
}
export interface MessageInputRef {
@@ -116,8 +140,10 @@ export interface MessageInputRef {
getNativeElement?: () => HTMLElement | null;
}
const MIN_INPUT_HEIGHT = 30;
const MIN_INPUT_HEIGHT_MOBILE = 30;
const MIN_INPUT_HEIGHT_DESKTOP = 46;
const MAX_INPUT_HEIGHT = 160;
const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE;
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
@@ -184,25 +210,22 @@ function getScrollableAncestorChain(element: HTMLElement | null): string[] {
return results;
}
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image);
if (!uri) {
return <View style={styles.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={styles.imageThumbnail} />;
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
{
value,
onChangeText,
onSubmit,
hasExternalContent = false,
allowEmptySubmit = false,
submitButtonAccessibilityLabel,
submitIcon = "arrow",
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
onPickImages,
attachments,
cwd,
attachmentMenuItems,
onAttachButtonRef,
onAddImages,
onRemoveImage,
client,
isReadyForDictation,
placeholder = "Message...",
@@ -223,6 +246,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
onHeightChange,
inputWrapperStyle,
},
ref,
) {
@@ -235,7 +259,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const sendKeys = useShortcutKeys("message-input-send");
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
const queueKeys = useShortcutKeys("message-input-queue");
const focusInputKeys = useShortcutKeys("focus-message-input");
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
const [isInputFocused, setIsInputFocused] = useState(false);
@@ -362,15 +385,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
sendAfterTranscriptRef.current = false;
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined;
// Respect send behavior setting: when "queue", dictation queues too.
if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) {
onQueue({ text: nextValue, images: imageAttachments });
onQueue({ text: nextValue, attachments, cwd });
onChangeText("");
} else {
onSubmit({
text: nextValue,
images: imageAttachments,
attachments,
cwd,
forceSend: isAgentRunning || undefined,
});
}
@@ -384,7 +407,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
});
}
},
[onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior],
[onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior],
);
const handleDictationError = useCallback(
@@ -568,32 +591,43 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
const handleSendMessage = useCallback(() => {
const trimmed = value.trim();
if (!trimmed && images.length === 0) return;
if (!trimmed && attachments.length === 0 && !hasExternalContent && !allowEmptySubmit) return;
const payload = {
text: trimmed,
images: images.length > 0 ? images : undefined,
attachments,
cwd,
forceSend: isAgentRunning || undefined,
};
onSubmit(payload);
inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(MIN_INPUT_HEIGHT);
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onSubmit, isAgentRunning, onHeightChange]);
}, [
allowEmptySubmit,
value,
attachments,
cwd,
onSubmit,
isAgentRunning,
onHeightChange,
hasExternalContent,
]);
const handleQueueMessage = useCallback(() => {
if (!onQueue) return;
const trimmed = value.trim();
if (!trimmed && images.length === 0) return;
if (!trimmed && attachments.length === 0) return;
const payload = {
text: trimmed,
images: images.length > 0 ? images : undefined,
attachments,
cwd,
};
onQueue(payload);
onChangeText("");
inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(MIN_INPUT_HEIGHT);
onHeightChange?.(MIN_INPUT_HEIGHT);
}, [value, images, onQueue, onChangeText, onHeightChange]);
}, [value, attachments, cwd, onQueue, onChangeText, onHeightChange]);
// Default send action: respects the sendBehavior setting.
// When "interrupt" (default), primary action sends immediately (interrupts).
@@ -928,20 +962,23 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
handleDefaultSendAction();
}
const hasImages = images.length > 0;
const hasSendableContent = value.trim().length > 0 || hasImages;
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
const hasAttachments = attachments.length > 0;
const hasRealContent = value.trim().length > 0 || hasAttachments;
const hasSendableContent = hasRealContent || hasExternalContent;
const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading;
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading));
const defaultActionQueues = defaultSendBehavior === "queue" && isAgentRunning;
const submitAccessibilityLabel = canPressLoadingButton
const defaultSubmitAccessibilityLabel = canPressLoadingButton
? "Interrupt agent"
: defaultActionQueues
? "Queue message"
: isAgentRunning
? "Send and interrupt"
: "Send message";
const submitAccessibilityLabel =
submitButtonAccessibilityLabel ?? defaultSubmitAccessibilityLabel;
const handleInputChange = useCallback(
(nextValue: string) => {
@@ -960,37 +997,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
{images.map((image, index) => (
<Pressable
key={`${image.id}-${index}`}
testID="message-input-image-pill"
style={styles.imagePill}
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
>
{({ hovered }) => (
<>
<ImageAttachmentThumbnail image={image} />
{onRemoveImage && (
<View
style={[
styles.removeImageButton,
(hovered || !isWeb) && styles.removeImageButtonVisible,
]}
>
<X size={theme.iconSize.md} color="white" />
</View>
)}
</>
)}
</Pressable>
))}
</View>
)}
<Animated.View
ref={inputWrapperRef}
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
>
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
<TextInput
@@ -1043,28 +1053,58 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.buttonRow}>
{/* Left: attachment button + leftContent slot */}
<View style={styles.leftButtonGroup}>
{onPickImages && (
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPickImages}
<DropdownMenuTrigger
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityLabel="Add attachment"
accessibilityRole="button"
testID="message-input-attach-button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Paperclip size={buttonIconSize} color={theme.colors.foreground} />
</Pressable>
{({ hovered }) => (
<View
ref={onAttachButtonRef}
collapsable={false}
style={styles.attachButtonAnchor}
>
<Plus
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</View>
)}
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
<Text style={styles.tooltipText}>Add attachment</Text>
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent
side="top"
align="start"
offset={8}
minWidth={220}
testID="message-input-attachment-menu"
>
{attachmentMenuItems.map((item) => (
<DropdownMenuItem
key={item.id}
testID={`message-input-attachment-menu-item-${item.id}`}
disabled={item.disabled}
onSelect={item.onSelect}
leading={item.icon ?? null}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{leftContent}
</View>
@@ -1092,13 +1132,21 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
isDictating && styles.voiceButtonRecording,
]}
>
{isDictating ? (
<Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={buttonIconSize} color={theme.colors.foreground} />
) : (
<Mic size={buttonIconSize} color={theme.colors.foreground} />
)}
{({ hovered }) =>
isDictating ? (
<Square size={buttonIconSize} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : (
<Mic
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)
}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -1123,31 +1171,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent>
</Tooltip>
{rightContent}
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleAlternateSendAction}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={({ hovered }) => [
styles.queueButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={buttonIconSize} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
{queueKeys ? (
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
@@ -1159,13 +1182,17 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : submitIcon === "return" ? (
<CornerDownLeft size={buttonIconSize} color="white" />
) : (
<ArrowUp size={buttonIconSize} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{defaultActionQueues ? "Queue" : "Send"}</Text>
<Text style={styles.tooltipText}>
{submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")}
</Text>
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
</View>
</TooltipContent>
@@ -1233,52 +1260,6 @@ const styles = StyleSheet.create(((theme: any) => ({
}
: {}),
},
imagePreviewContainer: {
flexDirection: "row",
gap: theme.spacing[2],
flexWrap: "wrap",
},
imagePill: {
position: "relative",
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: "hidden",
...(isWeb
? {
cursor: "pointer",
}
: {}),
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface2,
},
removeImageButton: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
opacity: 0,
...(isWeb
? {
transitionProperty: "opacity",
transitionDuration: "150ms",
}
: {}),
},
removeImageButtonVisible: {
opacity: 1,
},
textInputScrollWrapper: {
position: "relative",
},
@@ -1308,6 +1289,7 @@ const styles = StyleSheet.create(((theme: any) => ({
flexDirection: "row",
alignItems: "flex-end",
justifyContent: "space-between",
marginHorizontal: -6,
},
leftButtonGroup: {
minWidth: 0,
@@ -1315,13 +1297,13 @@ const styles = StyleSheet.create(((theme: any) => ({
flexGrow: 1,
flexDirection: "row",
alignItems: "flex-end",
gap: theme.spacing[1],
gap: theme.spacing[0],
},
rightButtonGroup: {
flexShrink: 0,
flexDirection: "row",
alignItems: "center",
gap: isWeb ? theme.spacing[2] : theme.spacing[1],
gap: theme.spacing[1],
},
attachButton: {
width: 28,
@@ -1330,6 +1312,12 @@ const styles = StyleSheet.create(((theme: any) => ({
alignItems: "center",
justifyContent: "center",
},
attachButtonAnchor: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
},
voiceButton: {
width: 28,
height: 28,
@@ -1340,14 +1328,6 @@ const styles = StyleSheet.create(((theme: any) => ({
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
},
queueButton: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
},
sendButton: {
width: 28,
height: 28,
@@ -1355,6 +1335,7 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.accent,
alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[1],
},
iconButtonHovered: {
backgroundColor: theme.colors.surface2,
+3 -3
View File
@@ -53,7 +53,6 @@ import Animated, {
withTiming,
} from "react-native-reanimated";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { theme } from "@/styles/theme";
import { createMarkdownStyles } from "@/styles/markdown-styles";
import { Colors, Fonts } from "@/constants/theme";
import * as Clipboard from "expo-clipboard";
@@ -341,6 +340,7 @@ export const UserMessage = memo(function UserMessage({
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
@@ -962,10 +962,10 @@ export const AssistantMessage = memo(function AssistantMessage({
client,
disableOuterSpacing,
}: AssistantMessageProps) {
const { theme, rt } = useUnistyles();
const { theme } = useUnistyles();
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
const markdownParser = useMemo(() => {
const parser = MarkdownIt({ typographer: true, linkify: true });
+20 -37
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import { Alert, Text, View } from "react-native";
import { useCallback, useRef, useState } from "react";
import { Alert, Text, TextInput, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
@@ -47,7 +47,6 @@ const styles = StyleSheet.create((theme) => ({
export interface PairLinkModalProps {
visible: boolean;
onClose: () => void;
targetServerId?: string;
onCancel?: () => void;
onSaved?: (result: {
profile: HostProfile;
@@ -57,39 +56,39 @@ export interface PairLinkModalProps {
}) => void;
}
export function PairLinkModal({
visible,
onClose,
onCancel,
onSaved,
targetServerId,
}: PairLinkModalProps) {
export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkModalProps) {
const { theme } = useUnistyles();
const daemons = useHosts();
const { upsertConnectionFromOfferUrl: upsertDaemonFromOfferUrl } = useHostMutations();
const isMobile = useIsCompactFormFactor();
const [offerUrl, setOfferUrl] = useState("");
const offerUrlRef = useRef("");
const inputRef = useRef<TextInput>(null);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const clearInput = useCallback(() => {
offerUrlRef.current = "";
inputRef.current?.clear();
}, []);
const handleClose = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
clearInput();
setErrorMessage("");
onClose();
}, [isSaving, onClose]);
}, [isSaving, clearInput, onClose]);
const handleCancel = useCallback(() => {
if (isSaving) return;
setOfferUrl("");
clearInput();
setErrorMessage("");
(onCancel ?? onClose)();
}, [isSaving, onCancel, onClose]);
}, [isSaving, clearInput, onCancel, onClose]);
const handleSave = useCallback(async () => {
if (isSaving) return;
const raw = offerUrl.trim();
const raw = offerUrlRef.current.trim();
if (!raw) {
setErrorMessage("Paste a pairing link (…/#offer=...)");
return;
@@ -122,15 +121,6 @@ export function PairLinkModal({
return;
}
if (targetServerId && parsedOffer.serverId !== targetServerId) {
const message = `That pairing link belongs to ${parsedOffer.serverId}, not ${targetServerId}.`;
setErrorMessage(message);
if (!isMobile) {
Alert.alert("Wrong daemon", message);
}
return;
}
try {
setIsSaving(true);
setErrorMessage("");
@@ -159,16 +149,7 @@ export function PairLinkModal({
} finally {
setIsSaving(false);
}
}, [
daemons,
handleClose,
isMobile,
isSaving,
offerUrl,
onSaved,
targetServerId,
upsertDaemonFromOfferUrl,
]);
}, [daemons, handleClose, isMobile, isSaving, onSaved, upsertDaemonFromOfferUrl]);
return (
<AdaptiveModalSheet
@@ -182,11 +163,13 @@ export function PairLinkModal({
<View style={styles.field}>
<Text style={styles.label}>Pairing link</Text>
<AdaptiveTextInput
ref={inputRef}
testID="pair-link-input"
nativeID="pair-link-input"
accessibilityLabel="pair-link-input"
value={offerUrl}
onChangeText={setOfferUrl}
onChangeText={(next) => {
offerUrlRef.current = next;
}}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
@@ -41,9 +41,9 @@ export function ProjectPickerModal() {
const recommendedPaths = useMemo(() => {
if (!workspaces) return [];
return Array.from(workspaces.values()).map(
(workspace) => workspace.projectRootPath || workspace.id,
);
return Array.from(workspaces.values())
.map((workspace) => workspace.projectRootPath)
.filter((path) => path.length > 0);
}, [workspaces]);
const directorySuggestionsQuery = useQuery({
@@ -25,8 +25,10 @@ import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import {
Archive,
ArrowUpRight,
CircleAlert,
ChevronDown,
ChevronRight,
@@ -35,6 +37,8 @@ import {
FolderPlus,
FolderGit2,
GitPullRequest,
Globe,
SquareTerminal,
Monitor,
MoreVertical,
Plus,
@@ -46,7 +50,10 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import {
buildHostNewWorkspaceRoute,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import {
type SidebarProjectEntry,
@@ -54,7 +61,13 @@ import {
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges";
import { ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
useContextMenu,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuTrigger,
@@ -80,9 +93,15 @@ import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import { createNameId } from "mnemonic-id";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
import { createNameId } from "mnemonic-id";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
@@ -175,11 +194,10 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null;
}
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const textColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const iconColor = getWorkspacePrIconColor(theme, hint.state);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
const handlePressIn = useCallback((event: GestureResponderEvent) => {
event.stopPropagation();
@@ -196,23 +214,71 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`${hint.state} pull request #${hint.number}`}
accessibilityLabel={`Pull request #${hint.number}`}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(false)}
style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]}
style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]}
>
<GitPullRequest size={12} color={iconColor} />
<Text style={[styles.workspacePrBadgeText, { color: textColor }]} numberOfLines={1}>
<GitPullRequest size={12} color={activeColor} />
<Text style={[prBadgeStyles.text, { color: activeColor }]} numberOfLines={1}>
#{hint.number}
</Text>
{isHovered && <ExternalLink size={10} color={textColor} />}
<ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
</Pressable>
);
}
const prBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
badgePressed: {
opacity: 0.82,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
},
}));
function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null {
const { theme } = useUnistyles();
if (!checks || checks.length === 0) return null;
const failed = checks.filter((c) => c.status === "failure").length;
if (failed === 0) return null;
const color = theme.colors.palette.red[500];
const label = `${failed} failed`;
return (
<View style={checksBadgeStyles.badge}>
<GitHubIcon size={10} color={color} />
<Text style={[checksBadgeStyles.text, { color }]}>{label}</Text>
</View>
);
}
const checksBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
},
}));
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -719,54 +785,18 @@ function ProjectHeaderRow({
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobileBreakpoint = useIsCompactFormFactor();
const handleBeginWorkspaceSetup = useCallback(() => {
if (!serverId) {
return;
}
router.navigate(
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as any,
);
onWorkspacePress?.();
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const toast = useToast();
const createWorktreeMutation = useMutation({
mutationFn: async () => {
if (!serverId) {
throw new Error("No server");
}
const client = getHostRuntimeStore().getClient(serverId);
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
throw new Error("Host is not connected");
}
const payload = await client.createPaseoWorktree({
cwd: project.iconWorkingDir,
worktreeSlug: createNameId(),
});
if (payload.error || !payload.workspace) {
throw new Error(payload.error ?? "Failed to create worktree");
}
return payload.workspace;
},
onSuccess: (workspace) => {
mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]);
onWorktreeCreated?.(workspace.id);
onWorkspacePress?.();
router.navigate(
prepareWorkspaceTab({
serverId: serverId!,
workspaceId: workspace.id,
target: { kind: "draft", draftId: "new" },
}) as any,
);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : String(error));
},
});
useKeyboardActionHandler({
handlerId: `worktree-new-${project.projectKey}`,
actions: ["worktree.new"],
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
priority: 0,
handle: () => {
createWorktreeMutation.mutate();
return true;
},
});
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -807,14 +837,8 @@ function ProjectHeaderRow({
{canCreateWorktree ? (
<NewWorktreeButton
displayName={displayName}
onPress={() => createWorktreeMutation.mutate()}
visible={
isHovered ||
platformIsNative ||
isMobileBreakpoint ||
createWorktreeMutation.isPending
}
loading={createWorktreeMutation.isPending}
onPress={handleBeginWorkspaceSetup}
visible={isHovered || platformIsNative || isMobileBreakpoint}
showShortcutHint={isProjectActive}
testID={`sidebar-project-new-worktree-${project.projectKey}`}
/>
@@ -937,10 +961,14 @@ function WorkspaceRowInner({
const { theme } = useUnistyles();
const isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
enabled: workspace.workspaceKind !== "directory",
cwd: workspaceDirectory ?? "",
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
});
const interaction = useLongPressDragInteraction({
drag,
@@ -955,122 +983,145 @@ function WorkspaceRowInner({
onPress();
}, [interaction.didLongPressRef, onPress]);
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
return (
<View
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<Pressable
disabled={isArchiving}
style={({ pressed }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
<View
style={styles.workspaceRowContainer}
onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={{ selected }}
style={({ pressed }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
]}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || platformIsNative || isCompact) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text
style={[
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
]}
numberOfLines={1}
>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{hasRunningService ? (
<Globe size={12} color={theme.colors.palette.blue[500]} />
) : (
<SquareTerminal size={12} color={theme.colors.palette.blue[500]} />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
</View>
) : null}
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
{onArchive && (isHovered || isTouchPlatform) ? (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={({ hovered = false }) => [
styles.kebabButton,
hovered && styles.kebabButtonHovered,
]}
accessibilityRole="button"
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</View>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
{({ hovered }) => (
<MoreVertical
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
trailing={
archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null
}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</View>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
</View>
) : null}
</Pressable>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<ChecksBadge checks={prHint.checks} />
<PrBadge hint={prHint} />
</View>
) : null}
</Pressable>
</View>
</WorkspaceHoverCard>
);
}
@@ -1104,12 +1155,17 @@ function WorkspaceRowWithMenu({
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
actionId: "archive-worktree",
}),
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
@@ -1147,19 +1203,32 @@ function WorkspaceRowWithMenu({
if (!confirmed) {
return;
}
let workspaceDirectory: string;
try {
workspaceDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!workspaceDirectory) {
toast.error("Workspace path not available");
return;
}
redirectAfterArchive();
void archiveWorktree({
serverId: workspace.serverId,
cwd: workspace.workspaceId,
worktreePath: workspace.workspaceId,
})
.then(() => {
redirectAfterArchive();
})
.catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
toast.error(message);
});
cwd: workspaceDirectory,
worktreePath: workspaceDirectory,
}).catch((error) => {
const message = error instanceof Error ? error.message : "Failed to archive worktree";
toast.error(message);
});
})();
}, [
archiveWorktree,
@@ -1167,6 +1236,7 @@ function WorkspaceRowWithMenu({
redirectAfterArchive,
toast,
workspace.name,
workspace.workspaceDirectory,
workspace.serverId,
workspace.workspaceId,
]);
@@ -1195,17 +1265,20 @@ function WorkspaceRowWithMenu({
}
setIsArchivingWorkspace(true);
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
redirectAfterArchive();
void (async () => {
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
redirectAfterArchive();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
@@ -1217,9 +1290,19 @@ function WorkspaceRowWithMenu({
]);
const handleCopyPath = useCallback(() => {
void Clipboard.setStringAsync(workspace.workspaceId);
let workspaceDirectory: string;
try {
workspaceDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(workspaceDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceId]);
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
@@ -1267,6 +1350,163 @@ function WorkspaceRowWithMenu({
);
}
function NonGitProjectRowWithMenuContent({
project,
displayName,
iconDataUri,
workspace,
selected,
onPress,
shortcutNumber,
showShortcutBadge,
drag,
isDragging,
dragHandleProps,
}: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry;
selected: boolean;
onPress: () => void;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
const toast = useToast();
const contextMenu = useContextMenu();
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection();
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const redirectAfterArchive = useCallback(() => {
if (
activeWorkspaceSelection?.serverId !== workspace.serverId ||
activeWorkspaceSelection.workspaceId !== workspace.workspaceId
) {
return;
}
router.replace(
buildWorkspaceArchiveRedirectRoute({
serverId: workspace.serverId,
archivedWorkspaceId: workspace.workspaceId,
workspaces: sessionWorkspaces.values(),
}) as any,
);
}, [activeWorkspaceSelection, sessionWorkspaces, workspace.serverId, workspace.workspaceId]);
const handleArchiveWorkspace = useCallback(() => {
if (isArchivingWorkspace) {
return;
}
void (async () => {
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
redirectAfterArchive();
void (async () => {
try {
const payload = await client.archiveWorkspace(workspace.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
})();
})();
}, [
isArchivingWorkspace,
redirectAfterArchive,
toast,
workspace.name,
workspace.serverId,
workspace.workspaceId,
]);
return (
<>
<ProjectHeaderRow
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
selected={selected}
chevron={null}
onPress={onPress}
serverId={null}
canCreateWorktree={false}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
isArchiving={isArchivingWorkspace}
menuController={contextMenu}
dragHandleProps={dragHandleProps}
/>
<ContextMenuContent
align="start"
width={220}
mobileMode="sheet"
testID={`sidebar-workspace-context-${workspace.workspaceKey}`}
>
<ContextMenuItem
testID={`sidebar-workspace-context-${workspace.workspaceKey}-archive`}
status={isArchivingWorkspace ? "pending" : "idle"}
pendingLabel="Hiding..."
destructive
onSelect={handleArchiveWorkspace}
>
Hide from sidebar
</ContextMenuItem>
</ContextMenuContent>
</>
);
}
function NonGitProjectRowWithMenu(props: {
project: SidebarProjectEntry;
displayName: string;
iconDataUri: string | null;
workspace: SidebarWorkspaceEntry;
selected: boolean;
onPress: () => void;
shortcutNumber: number | null;
showShortcutBadge: boolean;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
return (
<ContextMenu>
<NonGitProjectRowWithMenuContent {...props} />
</ContextMenu>
);
}
function FlattenedProjectRow({
project,
displayName,
@@ -1302,6 +1542,24 @@ function FlattenedProjectRow({
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
}) {
if (project.projectKind === "directory") {
return (
<NonGitProjectRowWithMenu
project={project}
displayName={displayName}
iconDataUri={iconDataUri}
workspace={rowModel.workspace}
selected={rowModel.selected}
onPress={onPress}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
);
}
return (
<ProjectHeaderRow
project={project}
@@ -1520,18 +1778,21 @@ function ProjectBlock({
}
setIsRemovingProject(true);
try {
for (const ws of project.workspaces) {
void Promise.allSettled(
project.workspaces.map(async (ws) => {
const payload = await client.archiveWorkspace(ws.workspaceId);
if (payload.error) {
throw new Error(payload.error);
}
}),
).then((results) => {
const failed = results.filter((r) => r.status === "rejected");
if (failed.length > 0) {
toast.error("Failed to remove some workspaces");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to remove project");
} finally {
setIsRemovingProject(false);
}
});
})();
}, [isRemovingProject, serverId, displayName, toast, project.workspaces]);
@@ -2212,43 +2473,16 @@ const styles = StyleSheet.create((theme) => ({
opacity: 1,
},
workspacePrBadgeRow: {
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspacePrBadge: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workspacePrBadgePressed: {
opacity: 0.82,
},
workspacePrBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
gap: theme.spacing[2],
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspaceCreatingText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
diffStatRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
flexShrink: 0,
},
diffStatAdditions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition,
},
diffStatDeletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion,
},
kebabButton: {
padding: 2,
borderRadius: 4,
@@ -0,0 +1,90 @@
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import type { LucideIcon } from "lucide-react-native";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
interface SidebarHeaderRowProps {
icon: LucideIcon;
label: string;
onPress: () => void;
isActive?: boolean;
testID?: string;
nativeID?: string;
accessibilityLabel?: string;
}
/**
* Top-of-sidebar header row: a sidebar-height pressable with an icon + label
* and a full-width border separator beneath. Used as the first element of a
* sidebar (workspace "Sessions", settings "Back to workspace"). Owns its own
* separator line so both sidebars converge on the same edge and padding.
*/
export function SidebarHeaderRow({
icon: Icon,
label,
onPress,
isActive = false,
testID,
nativeID,
accessibilityLabel,
}: SidebarHeaderRowProps) {
const { theme } = useUnistyles();
return (
<View style={styles.container}>
<Pressable
onPress={onPress}
testID={testID}
nativeID={nativeID}
accessible
accessibilityRole="button"
accessibilityLabel={accessibilityLabel ?? label}
style={({ hovered }) => [styles.button, (hovered || isActive) && styles.buttonHovered]}
>
{({ hovered }) => {
const isHighlighted = hovered || isActive;
const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted;
return (
<>
<Icon size={theme.iconSize.md} color={iconColor} />
<Text style={[styles.label, isHighlighted && styles.labelHighlighted]}>{label}</Text>
</>
);
}}
</Pressable>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
container: {
height: {
xs: HEADER_INNER_HEIGHT_MOBILE,
md: HEADER_INNER_HEIGHT,
},
paddingHorizontal: theme.spacing[2],
justifyContent: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
userSelect: "none",
},
button: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
},
buttonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
label: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
labelHighlighted: {
color: theme.colors.foreground,
},
}));
@@ -0,0 +1,18 @@
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
/**
* Edge-to-edge separator line for sidebars. Must render outside any horizontally
* padded container so the line runs the full sidebar width — matching the
* separator beneath the workspace sidebar's "New agent" header.
*/
export function SidebarSeparator() {
return <View style={styles.line} />;
}
const styles = StyleSheet.create((theme) => ({
line: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
}));
+16 -27
View File
@@ -89,12 +89,8 @@ interface SplitContainerProps {
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onSelectNewTabOption: (selection: {
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
paneId?: string;
}) => void;
onNewTerminalTab: (input: { paneId?: string }) => void;
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
onCreateDraftTab: (input: { paneId?: string }) => void;
onCreateTerminalTab: (input: { paneId?: string }) => void;
buildPaneContentModel: (input: {
paneId: string;
isPaneFocused: boolean;
@@ -258,9 +254,8 @@ export function SplitContainer({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId = "__new_tab_agent__",
onCreateDraftTab,
onCreateTerminalTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -528,9 +523,8 @@ export function SplitContainer({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -651,9 +645,8 @@ function SplitNodeView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
onCreateDraftTab,
onCreateTerminalTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -687,9 +680,8 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -738,9 +730,8 @@ function SplitNodeView({
onCloseTabsToLeft={onCloseTabsToLeft}
onCloseTabsToRight={onCloseTabsToRight}
onCloseOtherTabs={onCloseOtherTabs}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
buildPaneContentModel={buildPaneContentModel}
onFocusPane={onFocusPane}
onSplitPane={onSplitPane}
@@ -788,9 +779,8 @@ function SplitPaneView({
onCloseTabsToLeft,
onCloseTabsToRight,
onCloseOtherTabs,
onSelectNewTabOption,
onNewTerminalTab,
newTabAgentOptionId,
onCreateDraftTab,
onCreateTerminalTab,
buildPaneContentModel,
onFocusPane,
onSplitPane,
@@ -895,9 +885,8 @@ function SplitPaneView({
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
onSelectNewTabOption={onSelectNewTabOption}
onNewTerminalTab={onNewTerminalTab}
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
onCreateDraftTab={onCreateDraftTab}
onCreateTerminalTab={onCreateTerminalTab}
onReorderTabs={(nextTabs) => {
onReorderTabsInPane(
pane.id,
@@ -0,0 +1,14 @@
import type { ResolveStreamRenderStrategyInput, StreamStrategy } from "./stream-strategy";
import { createNativeStreamStrategy } from "./stream-strategy-native";
import { createWebStreamStrategy } from "./stream-strategy-web";
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput,
): StreamStrategy {
if (input.platform === "web") {
return createWebStreamStrategy({
isMobileBreakpoint: input.isMobileBreakpoint,
});
}
return createNativeStreamStrategy();
}
@@ -6,8 +6,6 @@ import type {
BottomAnchorLocalRequest,
BottomAnchorRouteRequest,
} from "./use-bottom-anchor-controller";
import { createNativeStreamStrategy } from "./stream-strategy-native";
import { createWebStreamStrategy } from "./stream-strategy-web";
type EdgeSlot = "header" | "footer";
type NeighborRelation = "above" | "below";
@@ -183,17 +181,6 @@ export function createStreamStrategy(config: StreamStrategyConfig): StreamStrate
};
}
export function resolveStreamRenderStrategy(
input: ResolveStreamRenderStrategyInput,
): StreamStrategy {
if (input.platform === "web") {
return createWebStreamStrategy({
isMobileBreakpoint: input.isMobileBreakpoint,
});
}
return createNativeStreamStrategy();
}
export function resolveBottomAnchorTransportBehavior(input: {
strategy: StreamStrategy;
isViewportSettling: boolean;
@@ -86,7 +86,7 @@ export function TerminalPane({ serverId, cwd, terminalId, isPaneFocused }: Termi
const isScreenFocused = useIsFocused();
const isAppVisible = useAppVisible();
const { theme } = useUnistyles();
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme.colors.terminal]);
const xtermTheme = useMemo(() => toXtermTheme(theme.colors.terminal), [theme]);
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobileView);
const openAgentList = usePanelStore((state) => state.openAgentList);
@@ -59,6 +59,7 @@ interface ToolCallSheetProviderProps {
}
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
const { theme } = useUnistyles();
const bottomSheetRef = useRef<BottomSheetModal>(null);
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
@@ -103,8 +104,8 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handleIndicator}
backgroundComponent={CustomSheetBackground}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
>
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
</BottomSheetModal>
@@ -120,6 +121,7 @@ interface ToolCallSheetContentProps {
}
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
const { theme } = useUnistyles();
const { toolName, displayName, detail, errorText, showLoadingSkeleton } = data;
const IconComponent = resolveToolCallIcon(toolName, detail);
@@ -129,13 +131,13 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
{/* Header */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<IconComponent size={20} color={styles.headerIcon.color} />
<IconComponent size={20} color={theme.colors.foreground} />
<Text style={styles.headerTitle} numberOfLines={1}>
{displayName}
</Text>
</View>
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={20} color={styles.closeIcon.color} />
<X size={20} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
@@ -155,12 +157,6 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
// ----- Styles -----
const styles = StyleSheet.create((theme) => ({
sheetBackground: {
backgroundColor: theme.colors.surface2,
},
handleIndicator: {
backgroundColor: theme.colors.palette.zinc[600],
},
container: {
flex: 1,
backgroundColor: theme.colors.surface2,
@@ -180,9 +176,6 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
flex: 1,
},
headerIcon: {
color: theme.colors.foreground,
},
headerTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
@@ -192,9 +185,6 @@ const styles = StyleSheet.create((theme) => ({
closeButton: {
padding: theme.spacing[2],
},
closeIcon: {
color: theme.colors.foregroundMuted,
},
content: {
flex: 1,
minHeight: 0,
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { File, Folder } from "lucide-react-native";
import { Theme } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { getAutocompleteScrollOffset } from "./autocomplete-utils";
export interface AutocompleteOption {
+1 -1
View File
@@ -181,7 +181,7 @@ export function Button({
]}
>
{renderIcon()}
<Text style={resolvedTextStyle}>{children}</Text>
{children != null ? <Text style={resolvedTextStyle}>{children}</Text> : null}
</Pressable>
);
}
+27 -14
View File
@@ -81,6 +81,8 @@ export interface ComboboxProps {
desktopFixedHeight?: number;
/** Content rendered above the scroll area on desktop (sticky header). */
stickyHeader?: ReactNode;
/** When true, selecting an option does not close the picker (multi-select mode). */
keepOpenOnSelect?: boolean;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
@@ -99,7 +101,21 @@ function toNumericStyleValue(value: unknown): number | null {
}
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />;
const { theme } = useUnistyles();
return (
<Animated.View
pointerEvents="none"
style={[
style,
{
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
]}
/>
);
}
export interface SearchInputProps {
@@ -262,9 +278,11 @@ export function Combobox({
desktopMinWidth,
desktopFixedHeight,
stickyHeader,
keepOpenOnSelect = false,
anchorRef,
children,
}: ComboboxProps): ReactElement {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const effectiveOptionsPosition = isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = !isMobile && isWeb && effectiveOptionsPosition === "above-search";
@@ -440,7 +458,7 @@ export function Combobox({
? clampedMeasuredTopStartLeft !== null && measuredTopStartBottom !== null
: floatingLeft !== null &&
floatingTop !== null &&
(hasNonZeroFloatingPosition || !referenceAtOrigin));
(hasNonZeroFloatingPosition || referenceAtOrigin));
const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition;
const shouldUseDesktopFade = !desktopPreventInitialFlash;
@@ -587,9 +605,11 @@ export function Combobox({
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
handleClose();
if (!keepOpenOnSelect) {
handleClose();
}
},
[handleClose, onSelect],
[handleClose, keepOpenOnSelect, onSelect],
);
const handleSubmitSearch = useCallback(() => {
@@ -709,12 +729,12 @@ export function Combobox({
enableDismissOnClose={enableDismissOnClose}
stackBehavior={stackBehavior}
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
handleIndicatorStyle={{ backgroundColor: theme.colors.palette.zinc[600] }}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.comboboxTitle}>{title}</Text>
<Text style={[styles.comboboxTitle, { color: theme.colors.foreground }]}>{title}</Text>
</View>
{stickyHeader}
{!children && searchable ? searchInput : null}
@@ -779,6 +799,7 @@ export function Combobox({
</>
) : (
<>
{stickyHeader}
{searchable ? searchInput : null}
{effectiveOptionsPosition === "above-search" ? (
<ScrollView
@@ -903,14 +924,6 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
bottomSheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
},
bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600],
},
bottomSheetHeader: {
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[2],
@@ -349,6 +349,7 @@ export function ContextMenuContent({
testID?: string;
}>): ReactElement | null {
const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles();
const webScrollbarStyle = useWebScrollbarStyle();
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
@@ -475,8 +476,14 @@ export function ContextMenuContent({
onChange={handleSheetChange}
backdropComponent={renderSheetBackdrop}
enablePanDownToClose
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.sheetHandle}
backgroundStyle={[
styles.sheetBackground,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
]}
handleIndicatorStyle={[styles.sheetHandle, { backgroundColor: theme.colors.surface2 }]}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
@@ -46,6 +46,8 @@ interface Rect {
type DropdownMenuContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
flushPendingSelect: () => void;
triggerRef: React.RefObject<View | null>;
};
@@ -167,19 +169,56 @@ export function DropdownMenu({
onOpenChange?: (open: boolean) => void;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const pendingSelectRef = useRef<(() => void) | null>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const flushPendingSelect = useCallback(() => {
const pendingSelect = pendingSelectRef.current;
pendingSelectRef.current = null;
if (!pendingSelect) return;
if (Platform.OS === "ios") {
// Native presenters such as PHPicker can hang if launched while an RN
// Modal is still completing dismissal on UIKit's side.
setTimeout(pendingSelect, 250);
return;
}
pendingSelect();
}, []);
const selectItem = useCallback(
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
if (!closeOnSelect) {
onSelect?.();
return;
}
if (Platform.OS === "ios") {
pendingSelectRef.current = onSelect ?? null;
setIsOpen(false);
return;
}
setIsOpen(false);
onSelect?.();
},
[setIsOpen],
);
const value = useMemo<DropdownMenuContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
selectItem,
flushPendingSelect,
triggerRef,
}),
[isOpen, setIsOpen],
[flushPendingSelect, isOpen, selectItem, setIsOpen],
);
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
@@ -266,7 +305,8 @@ export function DropdownMenuContent({
horizontalPadding?: number;
testID?: string;
}>): ReactElement | null {
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
const { open, setOpen, triggerRef, flushPendingSelect } =
useDropdownMenuContext("DropdownMenuContent");
const [modalVisible, setModalVisible] = useState(false);
const webScrollbarStyle = useWebScrollbarStyle();
const [closing, setClosing] = useState(false);
@@ -288,6 +328,12 @@ export function DropdownMenuContent({
}
}, [open, modalVisible]);
useEffect(() => {
if (!open && !modalVisible) {
flushPendingSelect();
}
}, [flushPendingSelect, modalVisible, open]);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
@@ -377,6 +423,7 @@ export function DropdownMenuContent({
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onDismiss={flushPendingSelect}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
@@ -501,7 +548,7 @@ export function DropdownMenuItem({
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
const { selectItem } = useDropdownMenuContext("DropdownMenuItem");
// Derive state from status prop (preferred) or legacy loading prop
const isPending = status === "pending" || loading;
@@ -539,10 +586,7 @@ export function DropdownMenuItem({
disabled={isDisabled}
onPress={() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
selectItem(onSelect, closeOnSelect);
}}
style={({ pressed, hovered }) => [
styles.item,
@@ -0,0 +1,116 @@
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { Pressable, Text } from "react-native";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Tooltip, TooltipTrigger } from "./tooltip";
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("@/constants/layout", () => ({
useIsCompactFormFactor: () => false,
}));
vi.mock("@gorhom/portal", () => ({
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("@gorhom/bottom-sheet", () => ({
useBottomSheetModalInternal: () => null,
}));
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
},
FadeIn: {},
FadeOut: {},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (styles: unknown) => styles,
},
}));
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
});
function renderTrigger({
childDisabled,
onPress,
}: {
childDisabled: boolean;
onPress: () => void;
}): void {
act(() => {
root?.render(
<Tooltip>
<TooltipTrigger asChild>
<Pressable disabled={childDisabled} onPress={onPress} testID="trigger">
<Text>Send</Text>
</Pressable>
</TooltipTrigger>
</Tooltip>,
);
});
}
function pressTrigger(): void {
const trigger = container?.querySelector('[data-testid="trigger"]');
expect(trigger).not.toBeNull();
act(() => {
trigger?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
describe("TooltipTrigger", () => {
it("keeps an asChild trigger disabled when the child is disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: true, onPress });
pressTrigger();
expect(onPress).not.toHaveBeenCalled();
});
it("keeps an asChild trigger interactive when the child is not disabled", () => {
const onPress = vi.fn();
renderTrigger({ childDisabled: false, onPress });
pressTrigger();
expect(onPress).toHaveBeenCalledTimes(1);
});
});

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