diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d04b0710..664e09036 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md b/docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md new file mode 100644 index 000000000..4a3f07554 --- /dev/null +++ b/docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md @@ -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//head:` +- 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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 30f617ccc..0c9fb09d3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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__URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. | +| `PASEO_SERVICE__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__URL`. | +| `PASEO_PORT` | Self alias for `PASEO_SERVICE__PORT`. | +| `HOST` | Bind host for the service process. | + +`` 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 diff --git a/docs/MOBILE_TESTING.md b/docs/MOBILE_TESTING.md index 12f543ad7..2120a1743 100644 --- a/docs/MOBILE_TESTING.md +++ b/docs/MOBILE_TESTING.md @@ -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. diff --git a/docs/UNISTYLES.md b/docs/UNISTYLES.md new file mode 100644 index 000000000..8ed81e891 --- /dev/null +++ b/docs/UNISTYLES.md @@ -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 + + +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 + + + {children} + + + +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); + + +``` + +`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(); + + +``` + +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(); + + + {title} + +``` + +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(); + + +``` + +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]() diff --git a/nix/package.nix b/nix/package.nix index a718e5261..292920eee 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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). diff --git a/package-lock.json b/package-lock.json index 1ff13c925..6015b273c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 311cbd787..3534e264f 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index c0b5b2c4f..96b8d5e2c 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/archive-tab.spec.ts @@ -8,6 +8,7 @@ import { createIdleAgent, expectSessionRowVisible, expectWorkspaceArchiveOutcome, + expectWorkspaceTabHidden, openSessions, openWorkspaceWithAgents, primeAdditionalPage, @@ -19,7 +20,7 @@ test.describe("Archive tab reconciliation", () => { let client: Awaited>; let tempRepo: { path: string; cleanup: () => Promise }; - 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, diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 9e319e851..2962d1336 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -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 { + 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}`, diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index e660be007..faec0bc19 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -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) => { diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index c7d1d8f4e..aae05eb31 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -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).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); } diff --git a/packages/app/e2e/helpers/launcher.ts b/packages/app/e2e/helpers/launcher.ts new file mode 100644 index 000000000..887717e46 --- /dev/null +++ b/packages/app/e2e/helpers/launcher.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + successLocator: ReturnType, + timeout = 5_000, +): Promise { + 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, + durationMs = 2_000, + intervalMs = 30, +): Promise { + 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 { + return createTempGitRepo(prefix); +} diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index e9d9a401a..2f199f100 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -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 { 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 { @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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") ?? ""), - ); - throw new Error( - [ - "Expected a newly created workspace to load.", - `Current URL: ${page.url()}`, - `Sidebar rows: ${sidebarWorkspaceRowIds.join(", ") || ""}`, - ].join("\n"), - ); + throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`); } const createdWorkspaceRow = page.getByTestId( diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index c6afdf37b..a47790057 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -8,6 +8,10 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes"; export type TerminalPerfDaemonClient = { connect(): Promise; close(): Promise; + 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 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 { @@ -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 { // 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"]'); diff --git a/packages/app/e2e/helpers/workspace-lifecycle.ts b/packages/app/e2e/helpers/workspace-lifecycle.ts new file mode 100644 index 000000000..a462bbfd1 --- /dev/null +++ b/packages/app/e2e/helpers/workspace-lifecycle.ts @@ -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 { + 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 { + await clickTerminal(page); + await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 }); +} + +export async function createAgentChatFromLauncher(page: Page): Promise { + 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); +} diff --git a/packages/app/e2e/helpers/workspace-setup.ts b/packages/app/e2e/helpers/workspace-setup.ts new file mode 100644 index 000000000..37dd71406 --- /dev/null +++ b/packages/app/e2e/helpers/workspace-setup.ts @@ -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; + close(): Promise; + 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 { + 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 { + 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 { + 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 { + await page + .locator('[data-testid^="sidebar-project-row-"]') + .filter({ hasText: projectNameFromPath(repoPath) }) + .first() + .hover(); +} + +export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise { + 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 { + 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 { + 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 { + await workspaceSetupDialog(page) + .getByRole("button", { name: /^Terminal Create the workspace/i }) + .click(); +} + +export async function waitForWorkspaceSetupDialogToClose( + page: Page, + timeoutMs = 45_000, +): Promise { + 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 { + // 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 { + await expect(page.getByTestId("workspace-setup-status")).toContainText(status, { + timeout: 30_000, + }); +} + +export async function expectSetupLogContains(page: Page, text: string): Promise { + await expect(page.getByTestId("workspace-setup-log")).toContainText(text, { + timeout: 30_000, + }); +} + +export async function expectNoSetupMessage(page: Page): Promise { + 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 { + 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); + }); + }); +} diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts index 9415a8d91..5ce7907dd 100644 --- a/packages/app/e2e/helpers/workspace-tabs.ts +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -22,6 +22,28 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise { }); } +export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise { + const allTabIds = await getWorkspaceTabTestIds(page); + return allTabIds.filter((id) => id.startsWith("workspace-tab-agent_")); +} + +export async function expectOnlyWorkspaceAgentTabsVisible( + page: Page, + expectedAgentIds: string[], +): Promise { + 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 { const toggle = page.getByTestId("workspace-explorer-toggle").first(); if (!(await toggle.isVisible().catch(() => false))) { diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index af64fa0f5..5bb66a400 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -6,6 +6,17 @@ export async function openNewAgentComposer(page: Page): Promise { 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 { + 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 { + 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 { + 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 }, diff --git a/packages/app/e2e/helpers/workspace.ts b/packages/app/e2e/helpers/workspace.ts index d49d67a87..4b6600367 100644 --- a/packages/app/e2e/helpers/workspace.ts +++ b/packages/app/e2e/helpers/workspace.ts @@ -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; cleanup: () => Promise; }; export const createTempGitRepo = async ( prefix = "paseo-e2e-", - options?: { withRemote?: boolean }, + options?: { + withRemote?: boolean; + paseoConfig?: Record; + files?: Array<{ path: string; content: string }>; + branches?: string[]; + }, ): Promise => { // 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 = {}; + 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; + } + }, + }; +} diff --git a/packages/app/e2e/launcher-tab.spec.ts b/packages/app/e2e/launcher-tab.spec.ts new file mode 100644 index 000000000..fd9fcec65 --- /dev/null +++ b/packages/app/e2e/launcher-tab.spec.ts @@ -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 }; +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); + }); +}); diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index e4aa23ff4..cdf582dab 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -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(); const createdWorktreeIds = new Set(); - 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(); + } + }); }); diff --git a/packages/app/e2e/settings-host-page.spec.ts b/packages/app/e2e/settings-host-page.spec.ts new file mode 100644 index 000000000..d95dc9503 --- /dev/null +++ b/packages/app/e2e/settings-host-page.spec.ts @@ -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(); + }); +}); diff --git a/packages/app/e2e/settings-navigation.spec.ts b/packages/app/e2e/settings-navigation.spec.ts new file mode 100644 index 000000000..f5425c4cc --- /dev/null +++ b/packages/app/e2e/settings-navigation.spec.ts @@ -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(); + }); +}); diff --git a/packages/app/e2e/sidebar-workspace.spec.ts b/packages/app/e2e/sidebar-workspace.spec.ts new file mode 100644 index 000000000..19fbce129 --- /dev/null +++ b/packages/app/e2e/sidebar-workspace.spec.ts @@ -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>, + 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(); + } + }); +}); diff --git a/packages/app/e2e/terminal-performance.spec.ts b/packages/app/e2e/terminal-performance.spec.ts index ea3a595d0..1392ca4ac 100644 --- a/packages/app/e2e/terminal-performance.spec.ts +++ b/packages/app/e2e/terminal-performance.spec.ts @@ -20,10 +20,15 @@ const KEYSTROKE_P95_BUDGET_MS = 150; test.describe("Terminal wire performance", () => { let client: TerminalPerfDaemonClient; let tempRepo: { path: string; cleanup: () => Promise }; + 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 diff --git a/packages/app/e2e/workspace-cwd.spec.ts b/packages/app/e2e/workspace-cwd.spec.ts new file mode 100644 index 000000000..5090e18ae --- /dev/null +++ b/packages/app/e2e/workspace-cwd.spec.ts @@ -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 { + 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(); + } + }); +}); diff --git a/packages/app/e2e/workspace-lifecycle.spec.ts b/packages/app/e2e/workspace-lifecycle.spec.ts new file mode 100644 index 000000000..ce2591199 --- /dev/null +++ b/packages/app/e2e/workspace-lifecycle.spec.ts @@ -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 { + 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(); + } + }); + }); +}); diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts new file mode 100644 index 000000000..1d8da9030 --- /dev/null +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -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(); + 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(); + } + }); +}); diff --git a/packages/app/e2e/workspace-setup-runtime.spec.ts b/packages/app/e2e/workspace-setup-runtime.spec.ts new file mode 100644 index 000000000..3315f6f5a --- /dev/null +++ b/packages/app/e2e/workspace-setup-runtime.spec.ts @@ -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 { + 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(); + } + }); +}); diff --git a/packages/app/e2e/workspace-setup-streaming.spec.ts b/packages/app/e2e/workspace-setup-streaming.spec.ts new file mode 100644 index 000000000..c2b143337 --- /dev/null +++ b/packages/app/e2e/workspace-setup-streaming.spec.ts @@ -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 { + 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(); + } + }); +}); diff --git a/packages/app/maestro/flows/land-in-chat.yaml b/packages/app/maestro/flows/land-in-chat.yaml new file mode 100644 index 000000000..8fe798478 --- /dev/null +++ b/packages/app/maestro/flows/land-in-chat.yaml @@ -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" diff --git a/packages/app/maestro/image-picker-repro.yaml b/packages/app/maestro/image-picker-repro.yaml new file mode 100644 index 000000000..95a550fc0 --- /dev/null +++ b/packages/app/maestro/image-picker-repro.yaml @@ -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" diff --git a/packages/app/maestro/relay-pairing.yaml b/packages/app/maestro/relay-pairing.yaml index a64d1afcc..02eb7579f 100644 --- a/packages/app/maestro/relay-pairing.yaml +++ b/packages/app/maestro/relay-pairing.yaml @@ -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" diff --git a/packages/app/maestro/relay-then-direct.yaml b/packages/app/maestro/relay-then-direct.yaml index 17ac17d10..0a9372fca 100644 --- a/packages/app/maestro/relay-then-direct.yaml +++ b/packages/app/maestro/relay-then-direct.yaml @@ -8,7 +8,7 @@ appId: sh.paseo - eraseText - inputText: ${PAIRING_OFFER_URL} - tapOn: - point: "75%,36%" + id: "pair-link-submit" - extendedWaitUntil: visible: "New agent" diff --git a/packages/app/package.json b/packages/app/package.json index b68b98b17..be11f8292 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 4422ba3c6..f35616127 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -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({ + ); @@ -788,7 +799,6 @@ function FaviconStatusSync() { function RootStack() { const storeReady = useStoreReady(); const { theme } = useUnistyles(); - return ( - + + + ); } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 591db90f7..06e7f53ed 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -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; } diff --git a/packages/app/src/app/h/[serverId]/index.tsx b/packages/app/src/app/h/[serverId]/index.tsx index 75d37bcf6..fa80724e5 100644 --- a/packages/app/src/app/h/[serverId]/index.tsx +++ b/packages/app/src/app/h/[serverId]/index.tsx @@ -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 ( - - - - ); -} - -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 ; } diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/h/[serverId]/new.tsx new file mode 100644 index 000000000..051822df3 --- /dev/null +++ b/packages/app/src/app/h/[serverId]/new.tsx @@ -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 ( + + ); +} diff --git a/packages/app/src/app/h/[serverId]/settings.tsx b/packages/app/src/app/h/[serverId]/settings.tsx index 28d1bf6a4..269e15d25 100644 --- a/packages/app/src/app/h/[serverId]/settings.tsx +++ b/packages/app/src/app/h/[serverId]/settings.tsx @@ -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 ( - + ); } diff --git a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx index bd584f8a0..a5de8a39f 100644 --- a/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx +++ b/packages/app/src/app/h/[serverId]/workspace/[workspaceId]/_layout.tsx @@ -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 ( @@ -44,6 +77,8 @@ export default function HostWorkspaceLayout() { } function HostWorkspaceLayoutContent() { + const navigation = useNavigation(); + const router = useRouter(); const rootNavigationState = useRootNavigationState(); const consumedIntentRef = useRef(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; diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 48fbbf0cd..342a9a8e8 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -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 ; + return ; } diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx index fe647aa50..562658082 100644 --- a/packages/app/src/app/pair-scan.tsx +++ b/packages/app/src/app/pair-scan.tsx @@ -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(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 ( - - Scan QR - router.back()}> - Close - - + router.back()} /> Not available on web @@ -282,12 +221,7 @@ export default function PairScanScreen() { return ( - - Scan QR - - Close - - + {!granted ? ( diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx deleted file mode 100644 index 6e425ddf0..000000000 --- a/packages/app/src/app/settings.tsx +++ /dev/null @@ -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 ; - } - - return null; -} diff --git a/packages/app/src/app/settings/[section].tsx b/packages/app/src/app/settings/[section].tsx new file mode 100644 index 000000000..8afddf1c0 --- /dev/null +++ b/packages/app/src/app/settings/[section].tsx @@ -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 ; +} diff --git a/packages/app/src/app/settings/hosts/[serverId].tsx b/packages/app/src/app/settings/hosts/[serverId].tsx new file mode 100644 index 000000000..02260acaa --- /dev/null +++ b/packages/app/src/app/settings/hosts/[serverId].tsx @@ -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 ( + + + + ); +} diff --git a/packages/app/src/app/settings/index.tsx b/packages/app/src/app/settings/index.tsx new file mode 100644 index 000000000..00e212c2a --- /dev/null +++ b/packages/app/src/app/settings/index.tsx @@ -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 ; + } + + return ; +} diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 9d4bf0718..a859212f3 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -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 } diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index cb709a4e2..d5e015d71 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -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} > - - - {title} - + + + + {title} + + {subtitle} + {headerActions ? {headerActions} : null} @@ -255,6 +272,35 @@ export function AdaptiveModalSheet({ ); } + const cardInner = ( + <> + + + + {title} + + {subtitle} + + {headerActions ? {headerActions} : null} + + + + + {scrollable ? ( + + {children} + + ) : ( + {children} + )} + + ); + const desktopContent = ( - - - - {title} - - {headerActions ? {headerActions} : null} - - - - - {scrollable ? ( - - {children} - + + {onFilesDropped ? ( + {cardInner} ) : ( - {children} + cardInner )} diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index e5364e06d..cfbd255fc 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -70,7 +70,9 @@ export function AddHostMethodModal({ @@ -80,7 +82,12 @@ export function AddHostMethodModal({ {isNative ? ( - + Scan QR code @@ -92,7 +99,9 @@ export function AddHostMethodModal({ diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 7e3bd9d29..5877a8d02 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -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(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 ( Host { + 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={} + testID="direct-host-submit" > {isSaving ? "Connecting..." : "Connect"} diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index d8a12afd3..231348280 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -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({ {value || placeholder} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : 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..."} - + {errorMessage ? {errorMessage} : null} {warningMessage ? {warningMessage} : null} @@ -229,7 +232,21 @@ interface DropdownSheetProps { } function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) { - return ; + const { theme } = useUnistyles(); + + return ( + + ); } export function DropdownSheet({ @@ -238,6 +255,7 @@ export function DropdownSheet({ onClose, children, }: DropdownSheetProps): ReactElement { + const { theme } = useUnistyles(); const bottomSheetRef = useRef(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" > - {title} + {title} - + { if (!event || typeof event !== "object") return null; const eventWithNative = event as { nativeEvent?: unknown; key?: unknown }; @@ -469,7 +489,7 @@ export function FormSelectTrigger({ {showLabel ? {label} : null} {isLoading ? ( - + ) : ( )} - + ); } @@ -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={} + icon={} showLabel={false} testID="draft-provider-select" /> @@ -591,9 +613,7 @@ export function AgentConfigRow({ disabled={disabled} isLoading={isModelLoading} onSelect={onSelectModel} - icon={ - - } + icon={} showLabel={false} testID="draft-model-select" /> @@ -607,7 +627,7 @@ export function AgentConfigRow({ placeholder="Default" disabled={disabled || modeOptions.length === 0} onSelect={onSelectMode} - icon={} + icon={} showLabel={false} testID="draft-mode-select" /> @@ -622,9 +642,7 @@ export function AgentConfigRow({ placeholder="Select..." disabled={disabled} onSelect={onSelectThinkingOption} - icon={ - - } + icon={} showLabel={false} /> @@ -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} /> - + - + ) : ( {displayBranch} - + )} @@ -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", diff --git a/packages/app/src/components/agent-input-submit.test.ts b/packages/app/src/components/agent-input-submit.test.ts index 7008f725a..62926b419 100644 --- a/packages/app/src/components/agent-input-submit.test.ts +++ b/packages/app/src/components/agent-input-submit.test.ts @@ -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(); + 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"); + }); }); diff --git a/packages/app/src/components/agent-input-submit.ts b/packages/app/src/components/agent-input-submit.ts index 8f2500075..977799a5f 100644 --- a/packages/app/src/components/agent-input-submit.ts +++ b/packages/app/src/components/agent-input-submit.ts @@ -1,28 +1,37 @@ export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed"; -export interface AgentInputSubmitActionInput { +export interface AgentInputSubmitActionInput { 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; + queueMessage: (input: { message: string; attachments: TAttachment[] }) => void; + submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise; 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( - input: AgentInputSubmitActionInput, +export async function submitAgentInput( + input: AgentInputSubmitActionInput, ): Promise { 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( } 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"; diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 404e4b37c..9db2e94c2 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -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 ( + + + { - 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({ > - {isActionDaemonUnavailable ? "Host offline" : "Archive this session?"} + {isActionDaemonUnavailable + ? "Host offline" + : "This agent is still running. Archiving it will stop the agent."} ({ flexWrap: "wrap", gap: theme.spacing[2], }, + providerIconWrap: { + width: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + }, rowMetaRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index e817f9dce..5f501ab8a 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -424,12 +424,7 @@ function ControlledStatusBar({ {thinkingOptions && thinkingOptions.length > 0 ? ( <> - + 0 ? ( <> - + { 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} /> diff --git a/packages/app/src/components/agent-stream-render-model.ts b/packages/app/src/components/agent-stream-render-model.ts index d8a2eeaa7..c5d9cda1e 100644 --- a/packages/app/src/components/agent-stream-render-model.ts +++ b/packages/app/src/components/agent-stream-render-model.ts @@ -8,8 +8,8 @@ import { import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy, - resolveStreamRenderStrategy, } from "./stream-strategy"; +import { resolveStreamRenderStrategy } from "./stream-strategy-resolver"; export type StreamRenderSegments = { historyVirtualized: StreamItem[]; diff --git a/packages/app/src/components/agent-stream-render-strategy.ts b/packages/app/src/components/agent-stream-render-strategy.ts index 38c64153e..9965bdeb3 100644 --- a/packages/app/src/components/agent-stream-render-strategy.ts +++ b/packages/app/src/components/agent-stream-render-strategy.ts @@ -1,2 +1,3 @@ export * from "./stream-strategy"; +export * from "./stream-strategy-resolver"; export * from "./agent-stream-render-model"; diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 3aaeed4cb..13c56d006 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -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 { + 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) => + React.createElement("span", { ...props, "data-icon": name }); + return { + X: createIcon("X"), + }; +}); + +vi.mock("expo-image", () => ({ + Image: (props: Record) => { + 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>("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(""); + 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(); + + expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull(); + expect(queryByTestId("attachment-lightbox-image")).toBeNull(); + }); + + it("renders the image when metadata is provided", () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + act(() => { + window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" })); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx new file mode 100644 index 000000000..dc67f40dc --- /dev/null +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -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 ( + + + + + + {hasError ? ( + Couldn't load image + ) : ( + {}} style={styles.imagePressable}> + setErrored(true)} + style={imageFillStyle} + /> + + )} + + + + + + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/attachment-pill.tsx b/packages/app/src/components/attachment-pill.tsx new file mode 100644 index 000000000..069b26000 --- /dev/null +++ b/packages/app/src/components/attachment-pill.tsx @@ -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 ( + + setIsBodyHovered(true)} + onHoverOut={() => setIsBodyHovered(false)} + accessibilityRole="button" + accessibilityLabel={openAccessibilityLabel} + style={styles.body} + > + {children} + + setIsCloseHovered(true)} + onHoverOut={() => setIsCloseHovered(false)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={removeAccessibilityLabel} + style={[styles.closeButton, !showRemove && styles.closeButtonHidden]} + > + + + + ); +} + +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", + }, +})); diff --git a/packages/app/src/components/branch-switcher.tsx b/packages/app/src/components/branch-switcher.tsx index 335cbe683..7207500ef 100644 --- a/packages/app/src/components/branch-switcher.tsx +++ b/packages/app/src/components/branch-switcher.tsx @@ -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 ? : null} - - {title} - + {title} ); @@ -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", diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/components/combined-model-selector.test.ts index 76abab566..75847f0ab 100644 --- a/packages/app/src/components/combined-model-selector.test.ts +++ b/packages/app/src/components/combined-model-selector.test.ts @@ -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"); }); }); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index 588db0704..65e208dca 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -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", diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts index 7951e0663..e96e5de72 100644 --- a/packages/app/src/components/combined-model-selector.utils.ts +++ b/packages/app/src/components/combined-model-selector.utils.ts @@ -13,7 +13,7 @@ export function resolveProviderLabel( ); } -export function buildSelectedTriggerLabel(providerLabel: string, modelLabel: string): string { +export function buildSelectedTriggerLabel(modelLabel: string): string { return modelLabel; } diff --git a/packages/app/src/components/composer-attachments.ts b/packages/app/src/components/composer-attachments.ts new file mode 100644 index 000000000..8159b84dd --- /dev/null +++ b/packages/app/src/components/composer-attachments.ts @@ -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, + }; +} diff --git a/packages/app/src/components/agent-input-area.status-controls.test.ts b/packages/app/src/components/composer.status-controls.test.ts similarity index 92% rename from packages/app/src/components/agent-input-area.status-controls.test.ts rename to packages/app/src/components/composer.status-controls.test.ts index 93b51a4ab..7491a87cb 100644 --- a/packages/app/src/components/agent-input-area.status-controls.test.ts +++ b/packages/app/src/components/composer.status-controls.test.ts @@ -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", () => { diff --git a/packages/app/src/components/agent-input-area.status-controls.ts b/packages/app/src/components/composer.status-controls.ts similarity index 100% rename from packages/app/src/components/agent-input-area.status-controls.ts rename to packages/app/src/components/composer.status-controls.ts diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx new file mode 100644 index 000000000..d148d2af0 --- /dev/null +++ b/packages/app/src/components/composer.test.tsx @@ -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; + queuedMessages: Map; + agentStreamHead: Map; + agentStreamTail: Map; + } + >; + setQueuedMessages: ReturnType; + setAgentStreamTail?: ReturnType; + setAgentStreamHead?: ReturnType; + } = { + 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) => Map) => { + const session = mockSessionState.sessions[serverId]; + session.agentStreamTail = updater(session.agentStreamTail); + }, + ); + const setAgentStreamHeadMock = vi.fn( + (serverId: string, updater: (prev: Map) => Map) => { + 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) => + 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) => { + 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>("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} + ) : ( + + ), + 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 ( + {children} + ); + }, + 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 ( + + ); + }, + DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => { + const menu = React.useContext(DropdownContext); + return menu?.open ?
{children}
: null; + }, + DropdownMenuItem: ({ + children, + onSelect, + testID, + disabled, + }: { + children: React.ReactNode; + onSelect?: () => void; + testID?: string; + disabled?: boolean; + }) => ( + + ), + }; +}); + +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; + }) => + open ? ( +
+ {options.map((option) => + renderOption ? ( + renderOption({ option, selected: false, active: false, onPress: vi.fn() }) + ) : ( + + ), + )} +
+ ) : null, + ComboboxItem: ({ + label, + selected, + onPress, + testID, + }: { + label: string; + selected?: boolean; + onPress: () => void; + testID?: string; + }) => ( + + ), +})); + +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(""); + 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 ( + + { + setAttachments((current) => { + const next = typeof updater === "function" ? updater(current) : updater; + latestAttachments = next; + return next; + }); + }} + isSubmitLoading={isSubmitLoading} + submitBehavior={submitBehavior} + cwd="/repo" + clearDraft={vi.fn()} + /> + + ); +} + +function renderComposer( + input: { + initialText?: string; + initialAttachments?: ComposerAttachment[]; + isSubmitLoading?: boolean; + submitBehavior?: "clear" | "preserve-and-lock"; + } = {}, +) { + act(() => { + root?.render(); + }); +} + +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 { + 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 { + 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", + }, + ], + }); + }); +}); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/composer.tsx similarity index 62% rename from packages/app/src/components/agent-input-area.tsx rename to packages/app/src/components/composer.tsx index 147df7dd7..cdac24034 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/composer.tsx @@ -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 ; + } + return ; +} + +interface ComposerProps { agentId: string; serverId: string; isPaneFocused: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; + /** 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(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); + const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); + const [githubSearchQuery, setGithubSearchQuery] = useState(""); + const [lightboxMetadata, setLightboxMetadata] = useState(null); + const attachButtonRef = useRef(null); const messageInputRef = useRef(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) | null + ((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise) | 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) => { @@ -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({
) : null; - const rightContent = ( - - {!isVoiceModeForAgent && hasAgent ? ( - - [ - styles.realtimeVoiceButton as any, - (hovered ? styles.iconButtonHovered : undefined) as any, - (!isConnected || voice?.isVoiceSwitching ? styles.buttonDisabled : undefined) as any, - ]} - > - {voice?.isVoiceSwitching ? ( - - ) : ( - - )} - - - - Voice mode - {voiceToggleKeys ? ( - - ) : null} - - - - ) : null} - {cancelButton} - - ); + const showVoiceModeButton = !isVoiceModeForAgent && hasAgent; + const rightContent = + showVoiceModeButton || cancelButton ? ( + + {showVoiceModeButton ? ( + + [ + styles.realtimeVoiceButton as any, + (hovered ? styles.iconButtonHovered : undefined) as any, + (!isConnected || voice?.isVoiceSwitching + ? styles.buttonDisabled + : undefined) as any, + ]} + > + {({ hovered }) => + voice?.isVoiceSwitching ? ( + + ) : ( + + ) + } + + + + Voice mode + {voiceToggleKeys ? ( + + ) : null} + + + + ) : null} + {cancelButton} + + ) : null; const hasContextWindowMeter = typeof agentState.contextWindowMaxTokens === "number" && @@ -659,6 +736,78 @@ export function AgentInputArea({
); + 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( + () => [ + { + id: "image", + label: "Add image", + icon: , + onSelect: () => { + void handlePickImage(); + }, + }, + { + id: "github", + label: "Add issue or PR", + icon: , + 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 ? ( @@ -670,8 +819,9 @@ export function AgentInputArea({ + setLightboxMetadata(null)} /> {/* Input area */} - + {/* Queue list */} {queuedMessages.length > 0 && ( @@ -718,18 +868,80 @@ export function AgentInputArea({ )} + {selectedAttachments.length > 0 ? ( + + {selectedAttachments.map((attachment, index) => { + if (attachment.kind === "image") { + return ( + handleOpenAttachment(attachment)} + onRemove={() => handleRemoveAttachment(index)} + openAccessibilityLabel="Open image attachment" + removeAccessibilityLabel="Remove image attachment" + disabled={isComposerLocked} + > + + + ); + } + + const item = attachment.item; + const kindLabel = item.kind === "pr" ? "PR" : "issue"; + return ( + handleOpenAttachment(attachment)} + onRemove={() => handleRemoveAttachment(index)} + openAccessibilityLabel={`Open ${kindLabel} #${item.number}`} + removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`} + disabled={isComposerLocked} + > + + + {item.kind === "pr" ? ( + + ) : ( + + )} + + + #{item.number} {item.title} + + + + ); + })} + + ) : null} + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} { + attachButtonRef.current = node; + }} onAddImages={addImages} - onRemoveImage={handleRemoveImage} client={client} isReadyForDictation={isDictationReady} placeholder={messagePlaceholder} @@ -757,6 +969,61 @@ export function AgentInputArea({ } }} onHeightChange={onComposerHeightChange} + inputWrapperStyle={inputWrapperStyle} + /> + {}} + 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 ; + } + const selected = selectedAttachments.some( + (attachment) => + attachment.kind !== "image" && + attachment.item.kind === item.kind && + attachment.item.number === item.number, + ); + return ( + handleToggleGithubItem(item)} + leadingSlot={ + item.kind === "pr" ? ( + + ) : ( + + ) + } + /> + ); + }} /> @@ -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", diff --git a/packages/app/src/components/context-window-meter.tsx b/packages/app/src/components/context-window-meter.tsx index 7b8f12c30..9e7576aaf 100644 --- a/packages/app/src/components/context-window-meter.tsx +++ b/packages/app/src/components/context-window-meter.tsx @@ -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; diff --git a/packages/app/src/components/diff-stat.tsx b/packages/app/src/components/diff-stat.tsx new file mode 100644 index 000000000..04f41aa79 --- /dev/null +++ b/packages/app/src/components/diff-stat.tsx @@ -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 ( + + +{formatDiffCount(additions)} + -{formatDiffCount(deletions)} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index cd873cc28..6a416cc40 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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({ )} - +{file.additions} - -{file.deletions} +
diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index e12cedcc4..e17f6072c 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -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 ( - router.back())} style={styles.backButton}> + router.back())} + style={styles.backButton} + accessibilityRole="button" + accessibilityLabel="Back" + > - {title && ( - - {title} - - )} + {title && {title}} + {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, - }, })); diff --git a/packages/app/src/components/headers/header-icon-badge.tsx b/packages/app/src/components/headers/header-icon-badge.tsx new file mode 100644 index 000000000..9fe5b28e4 --- /dev/null +++ b/packages/app/src/components/headers/header-icon-badge.tsx @@ -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 {children}; +} diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index 93ebb21a2..e0f5b2cc6 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -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", diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 09234adc7..7074dbb2b 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -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={ <> - {title && ( - - {title} - - )} + {title && {title}} } 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, diff --git a/packages/app/src/components/headers/screen-title.tsx b/packages/app/src/components/headers/screen-title.tsx new file mode 100644 index 000000000..57a60f60d --- /dev/null +++ b/packages/app/src/components/headers/screen-title.tsx @@ -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; +} + +/** + * 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 ( + + {children} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + text: { + flexShrink: 1, + fontSize: theme.fontSize.base, + fontWeight: { + xs: "400", + md: "300", + }, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 7ec33e7d0..688cda0ea 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -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 ( - [ - styles.newAgentButton, - hovered && styles.newAgentButtonHovered, - isActive && styles.newAgentButtonActive, - ]} - testID="sidebar-sessions" - accessible - accessibilityRole="button" - accessibilityLabel="Sessions" - onPress={onPress} - > - {({ hovered }) => ( - <> - - - Sessions - - - )} - - ); -} - 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" > - - - - - + {isInitialLoad ? ( @@ -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({ {padding.top > 0 ? : null} - - - - - + {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", diff --git a/packages/app/src/components/message-input.test.tsx b/packages/app/src/components/message-input.test.tsx new file mode 100644 index 000000000..fdf329e36 --- /dev/null +++ b/packages/app/src/components/message-input.test.tsx @@ -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) => + 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 }) => 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) => + asChild ? ( + <>{children} + ) : ( + + ), + 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; + }) => ( + + ), + DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + onSelect, + testID, + disabled, + }: { + children: React.ReactNode; + onSelect?: () => void; + testID?: string; + disabled?: boolean; + }) => ( + + ), +})); + +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(""); + 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( + , + ); + }); +} + +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 { + 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); + }); +}); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 3965435e0..28b855c93 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -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 ; - } - return ; -} - export const MessageInput = forwardRef(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(funct onSelectionChange: onSelectionChangeCallback, onFocusChange, onHeightChange, + inputWrapperStyle, }, ref, ) { @@ -235,7 +259,6 @@ export const MessageInput = forwardRef(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(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(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(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(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(funct return ( {/* Regular input */} - - {/* Image preview pills */} - {hasImages && ( - - {images.map((image, index) => ( - onRemoveImage(index) : undefined} - > - {({ hovered }) => ( - <> - - {onRemoveImage && ( - - - - )} - - )} - - ))} - - )} - + {/* Text input */} (funct {/* Left: attachment button + leftContent slot */} - {onPickImages && ( + - [ styles.attachButton, hovered && styles.iconButtonHovered, (!isConnected || disabled) && styles.buttonDisabled, ]} > - - + {({ hovered }) => ( + + + + )} + - Attach images + Add attachment - )} + + {attachmentMenuItems.map((item) => ( + + {item.label} + + ))} + + {leftContent} @@ -1092,13 +1132,21 @@ export const MessageInput = forwardRef(funct isDictating && styles.voiceButtonRecording, ]} > - {isDictating ? ( - - ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( - - ) : ( - - )} + {({ hovered }) => + isDictating ? ( + + ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( + + ) : ( + + ) + } @@ -1123,31 +1171,6 @@ export const MessageInput = forwardRef(funct {rightContent} - {hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && ( - - [ - styles.queueButton, - hovered && styles.iconButtonHovered, - (!isConnected || disabled) && styles.buttonDisabled, - ]} - > - - - - - Queue - {queueKeys ? ( - - ) : null} - - - - )} {shouldShowSendButton && ( (funct > {isSubmitLoading ? ( + ) : submitIcon === "return" ? ( + ) : ( )} - {defaultActionQueues ? "Queue" : "Send"} + + {submitButtonAccessibilityLabel ?? (defaultActionQueues ? "Queue" : "Send")} + {sendKeys ? : null} @@ -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, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 71c4531f8..f57f52bf5 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 }); diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index 178a36b23..8b7e4f43e 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -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(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 ( Pairing link { + offerUrlRef.current = next; + }} placeholder="https://app.paseo.sh/#offer=..." placeholderTextColor={theme.colors.foregroundMuted} style={styles.input} diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx index 4bc39860a..a4bd68c61 100644 --- a/packages/app/src/components/project-picker-modal.tsx +++ b/packages/app/src/components/project-picker-modal.tsx @@ -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({ diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 181fd0981..da2f46af0 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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 ( setIsHovered(true)} onHoverOut={() => setIsHovered(false)} - style={({ pressed }) => [styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed]} + style={({ pressed }) => [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]} > - - + + #{hint.number} - {isHovered && } + ); } +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 ( + + + {label} + + ); +} + +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 ? ( 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 ( - setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} - > - [ - 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}`} + + setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} > - - - - [ + 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}`} + > + + - {workspace.name} - - - - {isCreating ? Creating... : null} - {onArchive && (isHovered || platformIsNative || isCompact) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - + + + {workspace.name} + + + + {showScriptsIcon ? ( + + {hasRunningService ? ( + + ) : ( + )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={archiveShortcutKeys ? : null} - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} + + ) : null} + {isCreating ? Creating... : null} + {onArchive && (isHovered || isTouchPlatform) ? ( + + [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace actions" + testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} + {({ hovered }) => ( + + )} + + + {onCopyPath ? ( + } + onSelect={onCopyPath} + > + Copy path + + ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} + } + trailing={ + archiveShortcutKeys ? : null + } + status={archiveStatus} + pendingLabel={archivePendingLabel} + onSelect={onArchive} + > + {archiveLabel ?? "Archive"} + + + + ) : workspace.diffStat ? ( + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + - - {prHint ? ( - - - - ) : null} - - + {prHint ? ( + + + + + ) : null} +
+
+ ); } @@ -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 ( + <> + + + + Hide from sidebar + + + + ); +} + +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 ( + + + + ); +} + function FlattenedProjectRow({ project, displayName, @@ -1302,6 +1542,24 @@ function FlattenedProjectRow({ onRemoveProject?: () => void; removeProjectStatus?: "idle" | "pending"; }) { + if (project.projectKind === "directory") { + return ( + + ); + } + return ( { 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, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx new file mode 100644 index 000000000..26e796221 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -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 ( + + [styles.button, (hovered || isActive) && styles.buttonHovered]} + > + {({ hovered }) => { + const isHighlighted = hovered || isActive; + const iconColor = isHighlighted ? theme.colors.foreground : theme.colors.foregroundMuted; + return ( + <> + + {label} + + ); + }} + + + ); +} + +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, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-separator.tsx b/packages/app/src/components/sidebar/sidebar-separator.tsx new file mode 100644 index 000000000..864e56361 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-separator.tsx @@ -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 ; +} + +const styles = StyleSheet.create((theme) => ({ + line: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, +})); diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index cacd34453..5d504206b 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -89,12 +89,8 @@ interface SplitContainerProps { onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | 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, diff --git a/packages/app/src/components/stream-strategy-resolver.ts b/packages/app/src/components/stream-strategy-resolver.ts new file mode 100644 index 000000000..6aa9e3773 --- /dev/null +++ b/packages/app/src/components/stream-strategy-resolver.ts @@ -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(); +} diff --git a/packages/app/src/components/stream-strategy.ts b/packages/app/src/components/stream-strategy.ts index 47a8a05a6..bca2508a4 100644 --- a/packages/app/src/components/stream-strategy.ts +++ b/packages/app/src/components/stream-strategy.ts @@ -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; diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index ef1089f91..bd8f9fecc 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 03568b35e..617b82a64 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -59,6 +59,7 @@ interface ToolCallSheetProviderProps { } export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) { + const { theme } = useUnistyles(); const bottomSheetRef = useRef(null); const [sheetData, setSheetData] = React.useState(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 && } @@ -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 */} - + {displayName} - + @@ -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, diff --git a/packages/app/src/components/ui/autocomplete.tsx b/packages/app/src/components/ui/autocomplete.tsx index 95ee62f29..49f920148 100644 --- a/packages/app/src/components/ui/autocomplete.tsx +++ b/packages/app/src/components/ui/autocomplete.tsx @@ -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 { diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index 195b95d58..7da8dbfb5 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -181,7 +181,7 @@ export function Button({ ]} > {renderIcon()} - {children} + {children != null ? {children} : null} ); } diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 78a12cd8a..3bf33e278 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -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; children?: ReactNode; } @@ -99,7 +101,21 @@ function toNumericStyleValue(value: unknown): number | null { } function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { - return ; + const { theme } = useUnistyles(); + + return ( + + ); } 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" > - {title} + {title} {stickyHeader} {!children && searchable ? searchInput : null} @@ -779,6 +799,7 @@ export function Combobox({ ) : ( <> + {stickyHeader} {searchable ? searchInput : null} {effectiveOptionsPosition === "above-search" ? ( ({ 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], diff --git a/packages/app/src/components/ui/context-menu.tsx b/packages/app/src/components/ui/context-menu.tsx index 26e9e527c..854813214 100644 --- a/packages/app/src/components/ui/context-menu.tsx +++ b/packages/app/src/components/ui/context-menu.tsx @@ -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" > diff --git a/packages/app/src/components/ui/dropdown-menu.tsx b/packages/app/src/components/ui/dropdown-menu.tsx index c11dbce7d..42e6cfd58 100644 --- a/packages/app/src/components/ui/dropdown-menu.tsx +++ b/packages/app/src/components/ui/dropdown-menu.tsx @@ -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; }; @@ -167,19 +169,56 @@ export function DropdownMenu({ onOpenChange?: (open: boolean) => void; }>): ReactElement { const triggerRef = useRef(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( () => ({ open: isOpen, setOpen: setIsOpen, + selectItem, + flushPendingSelect, triggerRef, }), - [isOpen, setIsOpen], + [flushPendingSelect, isOpen, selectItem, setIsOpen], ); return {children}; @@ -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} > @@ -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, diff --git a/packages/app/src/components/ui/tooltip.test.tsx b/packages/app/src/components/ui/tooltip.test.tsx new file mode 100644 index 000000000..78f634574 --- /dev/null +++ b/packages/app/src/components/ui/tooltip.test.tsx @@ -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(""); + 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( + + + + Send + + + , + ); + }); +} + +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); + }); +}); diff --git a/packages/app/src/components/ui/tooltip.tsx b/packages/app/src/components/ui/tooltip.tsx index 7214e5fb5..add551c15 100644 --- a/packages/app/src/components/ui/tooltip.tsx +++ b/packages/app/src/components/ui/tooltip.tsx @@ -60,6 +60,28 @@ function useTooltipContext(componentName: string): TooltipContextValue { return ctx; } +// Tooltips should open on hover or keyboard focus, not when focus is restored +// programmatically (e.g. when a Modal closes and returns focus to its opener). +// Track the last input modality on web so TooltipTrigger can ignore focus +// events that weren't keyboard-driven. Native has no equivalent scenario. +let lastInputWasKeyboard = false; +if (isWeb && typeof window !== "undefined") { + const markKeyboard = () => { + lastInputWasKeyboard = true; + }; + const markPointer = () => { + lastInputWasKeyboard = false; + }; + window.addEventListener("keydown", markKeyboard, true); + window.addEventListener("mousedown", markPointer, true); + window.addEventListener("pointerdown", markPointer, true); + window.addEventListener("touchstart", markPointer, true); +} + +function shouldOpenOnFocus(): boolean { + return !isWeb || lastInputWasKeyboard; +} + function composeEventHandlers( original?: (event: E) => void, injected?: (event: E) => void, @@ -246,12 +268,10 @@ export function TooltipTrigger({ asChild = false, triggerRefProp = "ref", ...props -}: PropsWithChildren< - PressableProps & { - asChild?: boolean; - triggerRefProp?: string; - } ->): ReactElement { +}: PressableProps & { + asChild?: boolean; + triggerRefProp?: string; +}): ReactElement { const ctx = useTooltipContext("TooltipTrigger"); const openTimerRef = useRef | null>(null); @@ -306,6 +326,7 @@ export function TooltipTrigger({ (e: any) => { onFocus?.(e); if (!ctx.enabled || disabled) return; + if (!shouldOpenOnFocus()) return; clearOpenTimer(); ctx.setOpen(true); }, @@ -365,6 +386,7 @@ export function TooltipTrigger({ const mergedProps = { ...childProps, ...triggerProps, + disabled: childProps.disabled || disabled, onHoverIn: composeEventHandlers(childProps.onHoverIn, handleHoverIn), onHoverOut: composeEventHandlers(childProps.onHoverOut, handleHoverOut), onFocus: composeEventHandlers(childProps.onFocus, handleFocus), diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 3cf520db8..e431b1341 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { QrCode, Link2, ClipboardPaste, ExternalLink } from "lucide-react-native"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { QrCode, Link2, ClipboardPaste, ExternalLink, Settings } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; import { @@ -13,6 +13,7 @@ import { } from "@/runtime/host-runtime"; import { AddHostModal } from "./add-host-modal"; import { PairLinkModal } from "./pair-link-modal"; +import { Button } from "@/components/ui/button"; import { resolveAppVersion } from "@/utils/app-version"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; import { buildHostRootRoute } from "@/utils/host-routes"; @@ -20,6 +21,8 @@ import { PaseoLogo } from "@/components/icons/paseo-logo"; import { openExternalUrl } from "@/utils/open-external-url"; import { isWeb, isNative } from "@/constants/platform"; +const ThemedScrollView = withUnistyles(ScrollView); + type WelcomeAction = { key: "scan-qr" | "direct-connection" | "paste-pairing-link"; label: string; @@ -30,6 +33,10 @@ type WelcomeAction = { }; const styles = StyleSheet.create((theme) => ({ + scrollView: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, container: { flexGrow: 1, backgroundColor: theme.colors.surface0, @@ -47,14 +54,17 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontSize: theme.fontSize.xl, fontWeight: theme.fontWeight.medium, - marginBottom: theme.spacing[3], textAlign: "center", }, subtitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: theme.fontSize.sm, textAlign: "center", - marginBottom: theme.spacing[8], + }, + copyBlock: { + alignItems: "center", + gap: theme.spacing[2], + marginBottom: theme.spacing[12], }, actions: { width: "100%", @@ -117,19 +127,11 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, fontSize: theme.fontSize.sm, }, - setupHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - textAlign: "center", - marginBottom: theme.spacing[6], - lineHeight: theme.fontSize.sm * 1.5, - }, setupLink: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6, - marginBottom: theme.spacing[6], }, setupLinkText: { color: theme.colors.accent, @@ -142,6 +144,10 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", marginTop: theme.spacing[6], }, + settingsButton: { + alignSelf: "center", + marginTop: theme.spacing[6], + }, })); function useAnyHostOnline(serverIds: string[]): string | null { @@ -243,9 +249,7 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId)); useEffect(() => { - if (!anyOnlineServerId) { - return; - } + if (!anyOnlineServerId) return; router.replace(buildHostRootRoute(anyOnlineServerId)); }, [anyOnlineServerId, router]); @@ -305,8 +309,8 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { const showHostList = hosts.length > 0 && !anyOnlineServerId; return ( - - Welcome to Paseo - - {showHostList ? "Connecting to your hosts…" : "Connect to your host to start"} - - - {!showHostList && isNative && ( - <> - - You need the Paseo desktop app or server running on your computer first. - - openExternalUrl("https://paseo.sh")}> - Get started at paseo.sh - - - - )} + + Welcome to Paseo + {showHostList ? ( + Connecting to your hosts… + ) : ( + <> + Connect your computer to get started + {isNative ? ( + openExternalUrl("https://paseo.sh")} + > + paseo.sh + + + ) : null} + + )} + {actions.map((action) => { @@ -362,6 +369,17 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { ))} )} + + {appVersionText} @@ -382,6 +400,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) { finishOnboarding(serverId); }} /> - +
); } diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx new file mode 100644 index 000000000..730ba442b --- /dev/null +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -0,0 +1,414 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type PropsWithChildren, + type ReactElement, +} from "react"; +import { Dimensions, Platform, Text, View } from "react-native"; +import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ExternalLink } from "lucide-react-native"; +import { GitHubIcon } from "@/components/icons/github-icon"; +import { DiffStat } from "@/components/diff-stat"; +import { Pressable } from "react-native"; +import { Portal } from "@gorhom/portal"; +import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; +import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; +import { openExternalUrl } from "@/utils/open-external-url"; +import { PrBadge } from "@/components/sidebar-workspace-list"; +import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone"; + +interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +function measureElement(element: View): Promise { + return new Promise((resolve) => { + element.measureInWindow((x, y, width, height) => { + resolve({ x, y, width, height }); + }); + }); +} + +function computeHoverCardPosition({ + triggerRect, + contentSize, + displayArea, + offset, +}: { + triggerRect: Rect; + contentSize: { width: number; height: number }; + displayArea: Rect; + offset: number; +}): { x: number; y: number } { + let x = triggerRect.x + triggerRect.width + offset; + let y = triggerRect.y; + + // If it overflows right, try left + if (x + contentSize.width > displayArea.width - 8) { + x = triggerRect.x - contentSize.width - offset; + } + + // Constrain to screen + const padding = 8; + x = Math.max(padding, Math.min(displayArea.width - contentSize.width - padding, x)); + y = Math.max( + displayArea.y + padding, + Math.min(displayArea.y + displayArea.height - contentSize.height - padding, y), + ); + + return { x, y }; +} + +const HOVER_GRACE_MS = 100; +const HOVER_CARD_WIDTH = 260; + +interface WorkspaceHoverCardProps { + workspace: SidebarWorkspaceEntry; + prHint: PrHint | null; + isDragging: boolean; +} + +export function WorkspaceHoverCard({ + workspace, + prHint, + isDragging, + children, +}: PropsWithChildren): ReactElement { + // Desktop-only: skip on non-web platforms + if (Platform.OS !== "web") { + return <>{children}; + } + + return ( + + {children} + + ); +} + +function WorkspaceHoverCardDesktop({ + workspace, + prHint, + isDragging, + children, +}: PropsWithChildren): ReactElement { + const triggerRef = useRef(null); + const contentRef = useRef(null); + const [open, setOpen] = useState(false); + const graceTimerRef = useRef | null>(null); + const triggerHoveredRef = useRef(false); + + const hasContent = prHint !== null || !!workspace.diffStat; + + const clearGraceTimer = useCallback(() => { + if (graceTimerRef.current) { + clearTimeout(graceTimerRef.current); + graceTimerRef.current = null; + } + }, []); + + const scheduleClose = useCallback(() => { + if (graceTimerRef.current) return; + graceTimerRef.current = setTimeout(() => { + graceTimerRef.current = null; + setOpen(false); + }, HOVER_GRACE_MS); + }, []); + + const handleTriggerEnter = useCallback(() => { + triggerHoveredRef.current = true; + clearGraceTimer(); + if (!isDragging && hasContent) { + setOpen(true); + } + }, [clearGraceTimer, isDragging, hasContent]); + + const handleTriggerLeave = useCallback(() => { + triggerHoveredRef.current = false; + }, []); + + // While open, the safe zone covers trigger + content + the bridge between + // them. Close only fires when the pointer leaves the safe zone; re-entering + // it (including the bridge) cancels the pending close. + useHoverSafeZone({ + enabled: open, + triggerRef, + contentRef, + onEnterSafeZone: clearGraceTimer, + onLeaveSafeZone: scheduleClose, + }); + + // Close when drag starts + useEffect(() => { + if (isDragging) { + clearGraceTimer(); + setOpen(false); + } + }, [isDragging, clearGraceTimer]); + + // When content becomes available while trigger is already hovered, open the card. + useEffect(() => { + if (!hasContent || isDragging) return; + if (triggerHoveredRef.current) { + setOpen(true); + } + }, [hasContent, isDragging]); + + // Cleanup on unmount + useEffect(() => { + return () => { + clearGraceTimer(); + }; + }, [clearGraceTimer]); + + return ( + + {children} + {open && hasContent ? ( + + ) : null} + + ); +} + +function WorkspaceHoverCardContent({ + workspace, + prHint, + triggerRef, + contentRef, +}: { + workspace: SidebarWorkspaceEntry; + prHint: PrHint | null; + triggerRef: React.RefObject; + contentRef: React.RefObject; +}): ReactElement | null { + const { theme } = useUnistyles(); + const bottomSheetInternal = useBottomSheetModalInternal(true); + const [triggerRect, setTriggerRect] = useState(null); + const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + + // Measure trigger — same pattern as tooltip.tsx + useEffect(() => { + if (!triggerRef.current) return; + + let cancelled = false; + measureElement(triggerRef.current).then((rect) => { + if (cancelled) return; + setTriggerRect(rect); + }); + + return () => { + cancelled = true; + }; + }, [triggerRef]); + + // Compute position when both measurements are available + useEffect(() => { + if (!triggerRect || !contentSize) return; + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); + const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight }; + const result = computeHoverCardPosition({ + triggerRect, + contentSize, + displayArea, + offset: 4, + }); + setPosition(result); + }, [triggerRect, contentSize]); + + const handleLayout = useCallback( + (event: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = event.nativeEvent.layout; + setContentSize({ width, height }); + }, + [], + ); + + return ( + + + + + + {workspace.name} + + + {prHint || workspace.diffStat ? ( + + {workspace.diffStat ? ( + + ) : null} + {prHint ? : null} + + ) : null} + {prHint?.checks && prHint.checks.length > 0 ? ( + <> + + [styles.checksSummaryRow, hovered && styles.listRowHovered]} + onPress={() => void openExternalUrl(`${prHint.url}/checks`)} + > + {({ hovered }) => { + const checks = prHint.checks!; + const failed = checks.filter((c) => c.status === "failure").length; + const pending = checks.filter((c) => c.status === "pending").length; + + let badgeColor: string; + let badgeLabel: string; + + if (failed > 0) { + badgeColor = theme.colors.palette.red[500]; + badgeLabel = `${failed} failed`; + } else if (pending > 0) { + badgeColor = theme.colors.palette.amber[500]; + badgeLabel = `${pending} running`; + } else { + badgeColor = theme.colors.palette.green[500]; + badgeLabel = `${checks.length} passed`; + } + + return ( + <> + {hovered ? ( + + ) : ( + + )} + Checks + + + + {badgeLabel} + + + + ); + }} + + + ) : null} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + portalOverlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + zIndex: 1000, + }, + card: { + backgroundColor: theme.colors.surface1, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.lg, + paddingTop: theme.spacing[2], + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + zIndex: 1000, + }, + cardHeader: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + cardTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.normal, + flex: 1, + minWidth: 0, + }, + cardMetaRow: { + flexDirection: "row", + alignItems: "center", + gap: 6, + paddingHorizontal: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + separator: { + height: 1, + backgroundColor: theme.colors.border, + }, + listRowHovered: { + backgroundColor: theme.colors.surface2, + }, + checksSummaryRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + paddingHorizontal: theme.spacing[3], + paddingVertical: 6, + minHeight: 28, + }, + checksSummaryLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + color: theme.colors.foregroundMuted, + }, + checksSummaryCounts: { + flexDirection: "row", + alignItems: "center", + gap: 4, + flex: 1, + justifyContent: "flex-end", + }, + checksDot: { + width: 6, + height: 6, + borderRadius: 3, + }, + checksStatusText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.normal, + }, +})); diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx new file mode 100644 index 000000000..a0057f16c --- /dev/null +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -0,0 +1,361 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Image, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { createNameId } from "mnemonic-id"; +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { Composer } from "@/components/composer"; +import { useToast } from "@/contexts/toast-context"; +import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; +import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { encodeImages } from "@/utils/encode-images"; +import { toErrorMessage } from "@/utils/error-messages"; +import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; +import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; +import type { ImageAttachment, MessagePayload } from "./message-input"; + +function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { + if (!icon) { + return null; + } + return `data:${icon.mimeType};base64,${icon.data}`; +} + +export function WorkspaceSetupDialog() { + const { theme } = useUnistyles(); + const toast = useToast(); + const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); + const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); + const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); + const setAgents = useSessionStore((state) => state.setAgents); + const [errorMessage, setErrorMessage] = useState(null); + const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); + const [pendingAction, setPendingAction] = useState<"chat" | null>(null); + + const serverId = pendingWorkspaceSetup?.serverId ?? ""; + const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; + const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? ""; + const workspace = createdWorkspace; + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const chatDraft = useAgentInputDraft({ + draftKey: `workspace-setup:${serverId}:${sourceDirectory}`, + initialCwd: sourceDirectory, + composer: { + initialServerId: serverId || null, + initialValues: workspace?.workspaceDirectory + ? { workingDir: workspace.workspaceDirectory } + : undefined, + isVisible: pendingWorkspaceSetup !== null, + onlineServerIds: isConnected && serverId ? [serverId] : [], + lockedWorkingDir: workspace?.workspaceDirectory || sourceDirectory || undefined, + }, + }); + const composerState = chatDraft.composerState; + if (!composerState && pendingWorkspaceSetup) { + throw new Error("Workspace setup composer state is required"); + } + + const { icon: projectIcon } = useProjectIconQuery({ + serverId, + cwd: sourceDirectory, + }); + const iconDataUri = toProjectIconDataUri(projectIcon); + + useEffect(() => { + setErrorMessage(null); + setCreatedWorkspace(null); + setPendingAction(null); + }, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]); + + const handleClose = useCallback(() => { + clearWorkspaceSetup(); + }, [clearWorkspaceSetup]); + + const navigateAfterCreation = useCallback( + ( + workspaceId: string, + target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string }, + ) => { + if (!pendingWorkspaceSetup) { + return; + } + + clearWorkspaceSetup(); + navigateToPreparedWorkspaceTab({ + serverId: pendingWorkspaceSetup.serverId, + workspaceId, + target, + navigationMethod: pendingWorkspaceSetup.navigationMethod, + }); + }, + [clearWorkspaceSetup, pendingWorkspaceSetup], + ); + + const withConnectedClient = useCallback(() => { + if (!client || !isConnected) { + throw new Error("Host is not connected"); + } + return client; + }, [client, isConnected]); + + const ensureWorkspace = useCallback( + async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { + if (!pendingWorkspaceSetup) { + throw new Error("No workspace setup is pending"); + } + + if (createdWorkspace) { + return createdWorkspace; + } + + const connectedClient = withConnectedClient(); + const wirePayload = splitComposerAttachmentsForSubmit(input.attachments); + const payload = + pendingWorkspaceSetup.creationMethod === "create_worktree" + ? await connectedClient.createPaseoWorktree({ + cwd: input.cwd, + worktreeSlug: createNameId(), + ...(wirePayload.attachments.length > 0 + ? { attachments: wirePayload.attachments } + : {}), + }) + : await connectedClient.openProject(input.cwd); + + if (payload.error || !payload.workspace) { + throw new Error( + payload.error ?? + (pendingWorkspaceSetup.creationMethod === "create_worktree" + ? "Failed to create worktree" + : "Failed to open project"), + ); + } + + const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); + mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); + if (pendingWorkspaceSetup.creationMethod === "open_project") { + setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true); + } + setCreatedWorkspace(normalizedWorkspace); + return normalizedWorkspace; + }, + [ + createdWorkspace, + mergeWorkspaces, + pendingWorkspaceSetup, + setHasHydratedWorkspaces, + withConnectedClient, + ], + ); + + const getIsStillActive = useCallback(() => { + const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup; + return ( + current?.serverId === pendingWorkspaceSetup?.serverId && + current?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory && + current?.creationMethod === pendingWorkspaceSetup?.creationMethod + ); + }, [ + pendingWorkspaceSetup?.creationMethod, + pendingWorkspaceSetup?.serverId, + pendingWorkspaceSetup?.sourceDirectory, + ]); + + const handleCreateChatAgent = useCallback( + async ({ text, attachments, cwd }: MessagePayload) => { + try { + setPendingAction("chat"); + setErrorMessage(null); + const workspace = await ensureWorkspace({ cwd, attachments }); + const connectedClient = withConnectedClient(); + if (!composerState) { + throw new Error("Workspace setup composer state is required"); + } + + const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const encodedImages = await encodeImages(wirePayload.images); + const workspaceDirectory = requireWorkspaceExecutionAuthority({ + workspace, + }).workspaceDirectory; + const agent = await connectedClient.createAgent({ + provider: composerState.selectedProvider, + cwd: workspaceDirectory, + workspaceId: workspace.id, + ...(composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? { modeId: composerState.selectedMode } + : {}), + ...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}), + ...(composerState.effectiveThinkingOptionId + ? { thinkingOptionId: composerState.effectiveThinkingOptionId } + : {}), + ...(text.trim() ? { initialPrompt: text.trim() } : {}), + ...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}), + ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), + }); + + if (!getIsStillActive()) { + return; + } + + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); + return next; + }); + navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id }); + } catch (error) { + const message = toErrorMessage(error); + setErrorMessage(message); + toast.error(message); + } finally { + if (getIsStillActive()) { + setPendingAction(null); + } + } + }, + [ + composerState, + getIsStillActive, + navigateAfterCreation, + serverId, + setAgents, + ensureWorkspace, + toast, + withConnectedClient, + ], + ); + + const workspaceTitle = + workspace?.name || + workspace?.projectDisplayName || + displayName || + sourceDirectory.split(/[\\/]/).filter(Boolean).pop() || + sourceDirectory; + + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(workspaceTitle); + const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); + + const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); + const handleFilesDropped = useCallback((files: ImageAttachment[]) => { + addImagesRef.current?.(files); + }, []); + const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => { + addImagesRef.current = addImages; + }, []); + + const composerInputWrapperStyle = useMemo( + () => ({ backgroundColor: theme.colors.surface2 }), + [theme.colors.surface2], + ); + + if (!pendingWorkspaceSetup || !sourceDirectory) { + return null; + } + + const subtitleContent = ( + + {iconDataUri ? ( + + ) : ( + + {placeholderInitial} + + )} + + {workspaceTitle} + + + ); + + return ( + + + + + + {errorMessage ? {errorMessage} : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + subtitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + projectIcon: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + }, + projectIconFallback: { + width: theme.iconSize.md, + height: theme.iconSize.md, + borderRadius: theme.borderRadius.sm, + borderWidth: 1, + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + }, + projectIconFallbackText: { + color: theme.colors.foregroundMuted, + fontSize: 9, + }, + projectTitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + section: { + gap: theme.spacing[3], + marginHorizontal: -theme.spacing[6], + marginVertical: -theme.spacing[2], + }, + errorText: { + fontSize: theme.fontSize.sm, + color: theme.colors.destructive, + lineHeight: 20, + }, +})); diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts new file mode 100644 index 000000000..6b9b0003b --- /dev/null +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { WorkspaceScriptPayload } from "@server/shared/messages"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { patchWorkspaceScripts } from "./session-workspace-scripts"; + +function workspace(input: { + id: string; + workspaceDirectory?: string; + scripts?: WorkspaceDescriptor["scripts"]; +}): WorkspaceDescriptor { + return { + id: input.id, + projectId: "project-1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: input.workspaceDirectory ?? "/repo/main", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + status: "running", + diffStat: null, + scripts: input.scripts ?? [], + }; +} + +const runningScript: WorkspaceScriptPayload = { + scriptName: "web", + type: "service", + hostname: "web.paseo.localhost", + port: 3000, + proxyUrl: "http://web.paseo.localhost:6767", + lifecycle: "running", + health: "healthy", + exitCode: null, +}; + +describe("patchWorkspaceScripts", () => { + it("patches only the matching workspace scripts", () => { + const other = workspace({ id: "ws-other", workspaceDirectory: "/repo/other", scripts: [] }); + const current = new Map([ + ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })], + [other.id, other], + ]); + + const next = patchWorkspaceScripts(current, { + workspaceId: "ws-main", + scripts: [runningScript], + }); + + expect(next).not.toBe(current); + expect(next.get("ws-main")?.scripts).toEqual([runningScript]); + expect(next.get("ws-other")).toBe(other); + }); + + it("patches the matching workspace when the map key differs from the workspace id", () => { + const current = new Map([ + [ + "workspace-record-42", + workspace({ + id: "ws-main", + workspaceDirectory: "C:\\repo\\main\\", + scripts: [], + }), + ], + ]); + + const next = patchWorkspaceScripts(current, { + workspaceId: "ws-main", + scripts: [runningScript], + }); + + expect(next).not.toBe(current); + expect(next.get("workspace-record-42")?.scripts).toEqual([runningScript]); + }); + + it("ignores updates for unknown workspaces", () => { + const current = new Map([ + ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })], + ]); + + const next = patchWorkspaceScripts(current, { + workspaceId: "ws-missing", + scripts: [runningScript], + }); + + expect(next).toBe(current); + expect(next.get("ws-main")?.scripts).toEqual([]); + }); +}); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 6928c14a8..c53d084ee 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -13,6 +13,7 @@ import { } from "@/contexts/session-stream-reducers"; import type { ActivityLogPayload, + AgentAttachment, AgentStreamEventPayload, SessionOutboundMessage, } from "@server/shared/messages"; @@ -37,6 +38,7 @@ import { normalizeWorkspaceDescriptor, } from "@/stores/session-store"; import { useDraftStore } from "@/stores/draft-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible } from "@/utils/app-visibility"; @@ -51,7 +53,9 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; +import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; +import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { isNative } from "@/constants/platform"; // Re-export types from session-store and draft-store for backward compatibility @@ -178,6 +182,10 @@ type WorkspaceUpdatePayload = Extract< SessionOutboundMessage, { type: "workspace_update" } >["payload"]; +type WorkspaceSetupProgressPayload = Extract< + SessionOutboundMessage, + { type: "workspace_setup_progress" } +>["payload"]; const getAgentIdFromUpdate = (update: AgentUpdatePayload): string => update.kind === "remove" ? update.agentId : update.agent.id; @@ -283,6 +291,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); const updateSessionClient = useSessionStore((state) => state.updateSessionClient); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); + const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); + const removeWorkspaceSetup = useWorkspaceSetupStore((state) => state.removeWorkspace); + const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer); // Track focused agent for heartbeat const focusedAgentId = useSessionStore( @@ -292,7 +303,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const previousAgentStatusRef = useRef>(new Map()); const sendAgentMessageRef = useRef< - ((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise) | null + | (( + agentId: string, + message: string, + images?: AttachmentMetadata[], + attachments?: AgentAttachment[], + ) => Promise) + | null >(null); const sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); @@ -460,7 +477,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (queue && queue.length > 0) { const [next, ...rest] = queue; if (sendAgentMessageRef.current) { - void sendAgentMessageRef.current(agent.id, next.text, next.images); + const wirePayload = splitComposerAttachmentsForSubmit(next.attachments); + void sendAgentMessageRef.current( + agent.id, + next.text, + wirePayload.images, + wirePayload.attachments, + ); } setQueuedMessages(serverId, (prev) => { const updated = new Map(prev); @@ -811,6 +834,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ], ); + const applyWorkspaceSetupProgress = useCallback( + (payload: WorkspaceSetupProgressPayload) => { + upsertWorkspaceSetupProgress({ serverId, payload }); + }, + [serverId, upsertWorkspaceSetupProgress], + ); + const requestCanonicalCatchUp = useCallback( (agentId: string, cursor: { epoch: string; endSeq: number }) => { void client @@ -1153,7 +1183,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { - removeWorkspace(serverId, message.payload.id); + removeWorkspaceSetup({ serverId, workspaceId: String(message.payload.id) }); + removeWorkspace(serverId, String(message.payload.id)); return; } const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); @@ -1163,6 +1194,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider mergeWorkspaces(serverId, [workspace]); }); + const unsubScriptStatusUpdate = client.on("script_status_update", (message) => { + if (message.type !== "script_status_update") return; + setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload)); + }); + + const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => { + if (message.type !== "workspace_setup_progress") return; + applyWorkspaceSetupProgress(message.payload); + }); + + const unsubWorkspaceSetupStatusResponse = client.on( + "workspace_setup_status_response", + (message) => { + if (message.type !== "workspace_setup_status_response") return; + const { workspaceId, snapshot } = message.payload; + if (snapshot) { + applyWorkspaceSetupProgress({ workspaceId, ...snapshot }); + } + }, + ); + const unsubStatus = client.on("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -1512,6 +1564,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentStream(); unsubAgentTimeline(); unsubWorkspaceUpdate(); + unsubScriptStatusUpdate(); + unsubWorkspaceSetupProgress(); + unsubWorkspaceSetupStatusResponse(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -1537,8 +1592,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgentTimelineCursor, setInitializingAgents, setAgents, + setWorkspaces, mergeWorkspaces, removeWorkspace, + removeWorkspaceSetup, setAgentLastActivity, setPendingPermissions, setHasHydratedAgents, @@ -1546,13 +1603,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider notifyAgentAttention, requestCanonicalCatchUp, applyAgentUpdatePayload, + applyWorkspaceSetupProgress, applyTimelineResponse, voiceRuntime, voiceAudioEngine, ]); const sendAgentMessage = useCallback( - async (agentId: string, message: string, images?: AttachmentMetadata[]) => { + async ( + agentId: string, + message: string, + images?: AttachmentMetadata[], + attachments?: AgentAttachment[], + ) => { const messageId = generateMessageId(); const userMessage: StreamItem = { kind: "user_message", @@ -1592,6 +1655,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider .sendAgentMessage(agentId, message, { messageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), }) .catch((error) => { console.error("[Session] Failed to send agent message:", error); @@ -1660,6 +1724,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider config, initialPrompt, images, + attachments, git, worktreeName, requestId, @@ -1667,6 +1732,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider config: any; initialPrompt: string; images?: AttachmentMetadata[]; + attachments?: AgentAttachment[]; git?: any; worktreeName?: string; requestId?: string; @@ -1686,6 +1752,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider config, ...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}), ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), ...(git ? { git } : {}), ...(worktreeName ? { worktreeName } : {}), ...(requestId ? { requestId } : {}), @@ -1749,9 +1816,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Cleanup on unmount useEffect(() => { return () => { + clearWorkspaceSetupServer(serverId); clearSession(serverId); }; - }, [clearSession, serverId]); + }, [clearSession, clearWorkspaceSetupServer, serverId]); return children; } diff --git a/packages/app/src/contexts/session-workspace-scripts.ts b/packages/app/src/contexts/session-workspace-scripts.ts new file mode 100644 index 000000000..7a8ed4557 --- /dev/null +++ b/packages/app/src/contexts/session-workspace-scripts.ts @@ -0,0 +1,28 @@ +import type { ScriptStatusUpdateMessage } from "@server/shared/messages"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution"; + +export function patchWorkspaceScripts( + workspaces: Map, + update: ScriptStatusUpdateMessage["payload"], +): Map { + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceId: update.workspaceId, + }); + if (!workspaceKey) { + return workspaces; + } + + const existing = workspaces.get(workspaceKey); + if (!existing) { + return workspaces; + } + + const next = new Map(workspaces); + next.set(workspaceKey, { + ...existing, + scripts: update.scripts.map((s) => ({ ...s })), + }); + return next; +} diff --git a/packages/app/src/desktop/components/desktop-permission-row.tsx b/packages/app/src/desktop/components/desktop-permission-row.tsx index 6622a5b41..a6036818b 100644 --- a/packages/app/src/desktop/components/desktop-permission-row.tsx +++ b/packages/app/src/desktop/components/desktop-permission-row.tsx @@ -2,6 +2,7 @@ import { View, Text } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { Check } from "lucide-react-native"; import { Button } from "@/components/ui/button"; +import { settingsStyles } from "@/styles/settings"; import type { DesktopPermissionStatus } from "@/desktop/permissions/desktop-permissions"; export interface DesktopPermissionRowProps { @@ -38,9 +39,9 @@ export function DesktopPermissionRow({ state !== "not-granted"; return ( - - - {title} + + + {title} {isGranted ? ( @@ -74,25 +75,6 @@ export function DesktopPermissionRow({ } const styles = StyleSheet.create((theme) => ({ - audioRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[4], - paddingHorizontal: theme.spacing[4], - }, - audioRowBorder: { - borderTopWidth: 1, - borderTopColor: theme.colors.border, - }, - audioRowContent: { - flex: 1, - marginRight: theme.spacing[3], - }, - audioRowTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, permissionRowActions: { alignItems: "flex-end", gap: theme.spacing[1], @@ -117,7 +99,6 @@ const styles = StyleSheet.create((theme) => ({ }, permissionStatusText: { fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, permissionDetailText: { diff --git a/packages/app/src/desktop/components/desktop-permissions-section.tsx b/packages/app/src/desktop/components/desktop-permissions-section.tsx index 419032e0e..e11d35041 100644 --- a/packages/app/src/desktop/components/desktop-permissions-section.tsx +++ b/packages/app/src/desktop/components/desktop-permissions-section.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row"; import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions"; import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; export function DesktopPermissionsSection() { const { theme } = useUnistyles(); @@ -27,23 +28,23 @@ export function DesktopPermissionsSection() { const isBusy = isRefreshing || requestingPermission !== null; const notificationsGranted = snapshot?.notifications.state === "granted"; + const refreshButton = ( + + ); + return ( - - - Desktop Permissions - - + - + ); } const styles = StyleSheet.create((theme) => ({ - permissionSectionHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[2], - marginBottom: theme.spacing[3], - }, errorText: { fontSize: theme.fontSize.xs, paddingHorizontal: theme.spacing[4], diff --git a/packages/app/src/desktop/components/desktop-updates-section.tsx b/packages/app/src/desktop/components/desktop-updates-section.tsx index a6c77db19..60d2401d2 100644 --- a/packages/app/src/desktop/components/desktop-updates-section.tsx +++ b/packages/app/src/desktop/components/desktop-updates-section.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, Alert, Text, View } from "react-native"; import * as Clipboard from "expo-clipboard"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; import { ArrowUpRight, Play, Pause, RotateCw, Copy, FileText, Activity } from "lucide-react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -18,15 +19,12 @@ import { stopDesktopDaemon, } from "@/desktop/daemon/desktop-daemon"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; +import { resolveAppVersion } from "@/utils/app-version"; -export interface LocalDaemonSectionProps { - appVersion: string | null; - showLifecycleControls: boolean; -} - -export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalDaemonSectionProps) { +export function LocalDaemonSection() { const { theme } = useUnistyles(); const showSection = shouldUseDesktopDaemon(); + const appVersion = resolveAppVersion(); const { settings, updateSettings } = useAppSettings(); const { data, isLoading, error: statusError, setStatus, refetch } = useDaemonStatus(); const [isRestartingDaemon, setIsRestartingDaemon] = useState(false); @@ -236,22 +234,26 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD return null; } + const advancedSettingsButton = ( + + ); + return ( - - - Built-in daemon - - + {isLoading ? ( @@ -271,61 +273,57 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD {daemonStatusDetailText} - {showLifecycleControls ? ( - <> - - - Daemon management - - {isDaemonManagementPaused - ? "Paused. The built-in daemon stays stopped until you start it again." - : "Enabled. Paseo can manage the built-in daemon from the desktop app."} - - - - - - - {daemonActionLabel} - {daemonActionMessage} - {statusMessage ? {statusMessage} : null} - - - - - ) : null} + + + Daemon management + + {isDaemonManagementPaused + ? "Paused. The built-in daemon stays stopped until you start it again." + : "Enabled. Paseo can manage the built-in daemon from the desktop app."} + + + + + + + {daemonActionLabel} + {daemonActionMessage} + {statusMessage ? {statusMessage} : null} + + + Log file @@ -423,7 +421,7 @@ export function LocalDaemonSection({ appVersion, showLifecycleControls }: LocalD - + ); } @@ -448,7 +446,6 @@ const styles = StyleSheet.create((theme) => ({ valueText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.normal, }, valueSubtext: { color: theme.colors.foregroundMuted, @@ -482,17 +479,6 @@ const styles = StyleSheet.create((theme) => ({ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", lineHeight: 18, }, - codeBlock: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", - lineHeight: 18, - borderWidth: 1, - borderColor: theme.colors.border, - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface0, - padding: theme.spacing[3], - }, modalActions: { flexDirection: "row", justifyContent: "flex-end", diff --git a/packages/app/src/desktop/components/integrations-section.tsx b/packages/app/src/desktop/components/integrations-section.tsx index ea1b8b909..e5ec00812 100644 --- a/packages/app/src/desktop/components/integrations-section.tsx +++ b/packages/app/src/desktop/components/integrations-section.tsx @@ -4,6 +4,7 @@ import { useFocusEffect } from "@react-navigation/native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowUpRight, Terminal, Blocks, Check } from "lucide-react-native"; import { settingsStyles } from "@/styles/settings"; +import { SettingsSection } from "@/screens/settings/settings-section"; import { Button } from "@/components/ui/button"; import { openExternalUrl } from "@/utils/open-external-url"; import { @@ -79,39 +80,35 @@ export function IntegrationsSection() { return null; } + const trailing = ( + + + + + ); + return ( - - - Integrations - - - - - + @@ -166,7 +163,7 @@ export function IntegrationsSection() { )} - + ); } diff --git a/packages/app/src/desktop/components/pair-device-modal.tsx b/packages/app/src/desktop/components/pair-device-modal.tsx new file mode 100644 index 000000000..4a774b2c4 --- /dev/null +++ b/packages/app/src/desktop/components/pair-device-modal.tsx @@ -0,0 +1,23 @@ +import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { PairDeviceSection } from "@/desktop/components/pair-device-section"; + +export interface PairDeviceModalProps { + visible: boolean; + onClose: () => void; + testID?: string; +} + +export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { + return ( + + + + ); +} diff --git a/packages/app/src/desktop/components/pair-device-section.tsx b/packages/app/src/desktop/components/pair-device-section.tsx index ee63a78c6..36ed801aa 100644 --- a/packages/app/src/desktop/components/pair-device-section.tsx +++ b/packages/app/src/desktop/components/pair-device-section.tsx @@ -45,8 +45,7 @@ export function PairDeviceSection() { if (!showSection) return null; return ( - - Pair device + {pairingQuery.isPending ? ( diff --git a/packages/app/src/desktop/permissions/desktop-permissions.test.ts b/packages/app/src/desktop/permissions/desktop-permissions.test.ts index eb4c3e7d2..899b4e663 100644 --- a/packages/app/src/desktop/permissions/desktop-permissions.test.ts +++ b/packages/app/src/desktop/permissions/desktop-permissions.test.ts @@ -5,18 +5,35 @@ type MockPlatform = "web" | "ios" | "android"; type GlobalSnapshot = { Notification: unknown; navigatorDescriptor?: PropertyDescriptor; + windowDescriptor?: PropertyDescriptor; paseoDesktop: unknown; }; const originalGlobals: GlobalSnapshot = { Notification: (globalThis as { Notification?: unknown }).Notification, navigatorDescriptor: Object.getOwnPropertyDescriptor(globalThis, "navigator"), + windowDescriptor: Object.getOwnPropertyDescriptor(globalThis, "window"), paseoDesktop: typeof globalThis.window === "undefined" ? undefined : (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop, }; +function ensureWindow(): { paseoDesktop?: unknown } { + const existingWindow = (globalThis as { window?: { paseoDesktop?: unknown } }).window; + if (existingWindow) { + return existingWindow; + } + + const nextWindow: { paseoDesktop?: unknown } = {}; + Object.defineProperty(globalThis, "window", { + configurable: true, + writable: true, + value: nextWindow, + }); + return nextWindow; +} + function setNavigator(value: unknown): void { Object.defineProperty(globalThis, "navigator", { configurable: true, @@ -34,6 +51,12 @@ function restoreGlobals(): void { delete (globalThis as { navigator?: unknown }).navigator; } + if (originalGlobals.windowDescriptor) { + Object.defineProperty(globalThis, "window", originalGlobals.windowDescriptor); + } else { + delete (globalThis as { window?: unknown }).window; + } + if (typeof globalThis.window !== "undefined") { (globalThis.window as { paseoDesktop?: unknown }).paseoDesktop = originalGlobals.paseoDesktop; } @@ -58,7 +81,7 @@ describe("desktop-permissions", () => { expect(shouldShowDesktopPermissionSection()).toBe(false); - globalThis.window = { paseoDesktop: {} } as unknown as Window & typeof globalThis; + ensureWindow().paseoDesktop = {}; expect(shouldShowDesktopPermissionSection()).toBe(true); }); diff --git a/packages/app/src/hooks/use-active-worktree-new-action.ts b/packages/app/src/hooks/use-active-worktree-new-action.ts new file mode 100644 index 000000000..434ffe20f --- /dev/null +++ b/packages/app/src/hooks/use-active-worktree-new-action.ts @@ -0,0 +1,58 @@ +import { useCallback } from "react"; +import { router } from "expo-router"; +import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; +import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; +import { useSessionStore } from "@/stores/session-store"; +import { buildHostNewWorkspaceRoute } from "@/utils/host-routes"; +import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; + +const WORKTREE_NEW_ACTIONS: readonly KeyboardActionId[] = ["worktree.new"]; + +export function useActiveWorktreeNewAction() { + const selection = useNavigationActiveWorkspaceSelection(); + const serverId = selection?.serverId ?? null; + const workspaceId = selection?.workspaceId ?? null; + + const workingDir = useSessionStore((state) => { + if (!serverId || !workspaceId) { + return null; + } + const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId); + if (!workspace || workspace.projectKind !== "git") { + return null; + } + return workspace.projectRootPath; + }); + + const displayName = useSessionStore((state) => { + if (!serverId || !workspaceId) { + return null; + } + const workspace = state.sessions[serverId]?.workspaces?.get(workspaceId); + if (!workspace || workspace.projectKind !== "git") { + return null; + } + return workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId); + }); + + const handle = useCallback(() => { + if (!serverId || !workingDir) { + return false; + } + router.navigate( + buildHostNewWorkspaceRoute(serverId, workingDir, { + displayName: displayName ?? undefined, + }) as never, + ); + return true; + }, [serverId, workingDir, displayName]); + + useKeyboardActionHandler({ + handlerId: "worktree-new-active", + actions: WORKTREE_NEW_ACTIONS, + enabled: serverId !== null && workingDir !== null, + priority: 0, + handle, + }); +} diff --git a/packages/app/src/hooks/use-agent-form-state.test.ts b/packages/app/src/hooks/use-agent-form-state.test.ts index 8b9457797..1ea3fcd31 100644 --- a/packages/app/src/hooks/use-agent-form-state.test.ts +++ b/packages/app/src/hooks/use-agent-form-state.test.ts @@ -161,7 +161,7 @@ describe("useAgentFormState", () => { }, ]; - it("auto-selects the model's default thinking option when none is configured", () => { + it("does not auto-select a model on fresh drafts without preferences", () => { const resolved = __private__.resolveFormState( undefined, { provider: "codex" }, @@ -186,14 +186,14 @@ describe("useAgentFormState", () => { codexProviderMap, ); - expect(resolved.model).toBe("gpt-5.3-codex"); - expect(resolved.thinkingOptionId).toBe("xhigh"); + expect(resolved.model).toBe(""); + expect(resolved.thinkingOptionId).toBe(""); }); - it("prefers provider defaults on fresh drafts", () => { + it("auto-selects the model's default thinking option when model is preferred but thinking is not", () => { const resolved = __private__.resolveFormState( undefined, - { provider: "codex" }, + { provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } }, codexModels, { serverId: false, @@ -222,7 +222,7 @@ describe("useAgentFormState", () => { it("falls back to model default when saved thinking preference is invalid", () => { const resolved = __private__.resolveFormState( undefined, - { provider: "codex" }, + { provider: "codex", providerPreferences: { codex: { model: "gpt-5.3-codex" } } }, codexModels, { serverId: false, @@ -305,7 +305,7 @@ describe("useAgentFormState", () => { it("keeps an explicit initial thinking option when it is valid", () => { const resolved = __private__.resolveFormState( - { thinkingOptionId: "low" }, + { model: "gpt-5.3-codex", thinkingOptionId: "low" }, { provider: "codex" }, codexModels, { @@ -348,7 +348,7 @@ describe("useAgentFormState", () => { const resolved = __private__.resolveFormState( undefined, - { provider: "claude" }, + { provider: "claude", providerPreferences: { claude: { model: "default" } } }, claudeModels, { serverId: false, diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index b1df8ae9d..959a2d0e8 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -64,7 +64,7 @@ type UseAgentFormStateOptions = { onlineServerIds?: string[]; }; -type UseAgentFormStateResult = { +export type UseAgentFormStateResult = { selectedServerId: string | null; setSelectedServerId: (value: string | null) => void; setSelectedServerIdFromUser: (value: string | null) => void; @@ -91,7 +91,7 @@ type UseAgentFormStateResult = { isModelLoading: boolean; modelError: string | null; refreshProviderModels: () => void; - invalidateProviderModels: () => void; + refetchProviderModelsIfStale: () => void; setProviderAndModelFromUser: (provider: AgentProvider, modelId: string) => void; workingDirIsEmpty: boolean; persistFormPreferences: () => Promise; @@ -130,7 +130,7 @@ function resolveEffectiveModel( } const normalizedModelId = modelId.trim(); if (!normalizedModelId) { - return resolveDefaultModel(availableModels); + return null; } return ( availableModels.find((model) => model.id === normalizedModelId) ?? @@ -235,8 +235,6 @@ function resolveFormState( } else { result.model = defaultModelId; } - } else if (defaultModelId) { - result.model = defaultModelId; } else { result.model = ""; } @@ -362,11 +360,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const { entries: snapshotEntries, isLoading: snapshotIsLoading, - isFetching: snapshotIsFetching, error: snapshotError, refresh: refreshSnapshot, - invalidate: invalidateSnapshot, - } = useProvidersSnapshot(formState.serverId); + refetchIfStale: refetchSnapshotIfStale, + } = useProvidersSnapshot(formState.serverId, formState.workingDir); const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]); const snapshotProviderDefinitions = useMemo( @@ -415,7 +412,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const allProviderModels = snapshotAllProviderModels; const availableModels = snapshotSelectedProviderModels; const modeOptions = snapshotSelectedProviderModes; - const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching; + const isAllModelsLoading = snapshotIsLoading; // Combine initialValues with initialServerId for resolution const combinedInitialValues = useMemo((): FormInitialValues | undefined => { @@ -627,9 +624,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg refreshSnapshot(); }, [refreshSnapshot]); - const invalidateProviderModels = useCallback(() => { - invalidateSnapshot(); - }, [invalidateSnapshot]); + const refetchProviderModelsIfStale = useCallback(() => { + refetchSnapshotIfStale(); + }, [refetchSnapshotIfStale]); const persistFormPreferences = useCallback(async () => { const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -664,7 +661,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg const effectiveModel = resolveEffectiveModel(availableModels, formState.model); const resolvedModelId = effectiveModel?.id ?? formState.model; const availableThinkingOptions = effectiveModel?.thinkingOptions ?? []; - const isModelLoading = snapshotIsLoading || snapshotIsFetching; + const isModelLoading = snapshotIsLoading; const modelError = snapshotError; const workingDirIsEmpty = !formState.workingDir.trim(); @@ -697,7 +694,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg isModelLoading, modelError, refreshProviderModels, - invalidateProviderModels, + refetchProviderModelsIfStale, setProviderAndModelFromUser, workingDirIsEmpty, persistFormPreferences, @@ -729,7 +726,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg isModelLoading, modelError, refreshProviderModels, - invalidateProviderModels, + refetchProviderModelsIfStale, setProviderAndModelFromUser, workingDirIsEmpty, persistFormPreferences, diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx new file mode 100644 index 000000000..9d2edbd51 --- /dev/null +++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx @@ -0,0 +1,634 @@ +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDraftStore } from "@/stores/draft-store"; +import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; + +const { asyncStorage } = vi.hoisted(() => ({ + asyncStorage: new Map(), +})); + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: async (key: string) => asyncStorage.get(key) ?? null, + setItem: async (key: string, value: string) => { + asyncStorage.set(key, value); + }, + removeItem: async (key: string) => { + asyncStorage.delete(key); + }, + }, +})); + +vi.mock("@/attachments/service", () => ({ + garbageCollectAttachments: async () => undefined, +})); + +vi.mock("./use-agent-form-state", () => ({ + useAgentFormState: () => ({ + selectedServerId: "host-1", + setSelectedServerId: () => undefined, + setSelectedServerIdFromUser: () => undefined, + selectedProvider: "codex", + setProviderFromUser: () => undefined, + selectedMode: "auto", + setModeFromUser: () => undefined, + selectedModel: "", + setModelFromUser: () => undefined, + selectedThinkingOptionId: "", + setThinkingOptionFromUser: () => undefined, + workingDir: "/repo", + setWorkingDir: () => undefined, + setWorkingDirFromUser: () => undefined, + providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }], + providerDefinitionMap: new Map(), + agentDefinition: undefined, + modeOptions: [{ id: "auto", label: "Auto" }], + availableModels: [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ], + allProviderModels: new Map([ + [ + "codex", + [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ], + ], + ]), + isAllModelsLoading: false, + availableThinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + isModelLoading: false, + modelError: null, + refreshProviderModels: () => undefined, + setProviderAndModelFromUser: () => undefined, + workingDirIsEmpty: false, + persistFormPreferences: async () => undefined, + }), +})); + +let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft; +type DraftRecordForTest = ReturnType["drafts"][string]; + +beforeAll(async () => { + const storage = new Map(); + + Object.defineProperty(globalThis, "window", { + value: { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + }, + configurable: true, + }); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + value: true, + configurable: true, + }); + + ({ useAgentInputDraft } = await import("./use-agent-input-draft")); +}); + +describe("useAgentInputDraft live contract", () => { + beforeEach(() => { + asyncStorage.clear(); + const dom = new JSDOM("
", { + url: "http://localhost", + }); + + Object.defineProperty(globalThis, "document", { + value: dom.window.document, + configurable: true, + }); + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + }); + + useDraftStore.setState({ drafts: {}, createModalDraft: null }); + }); + + it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "attachment-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/1", + createdAt: 1, + fileName: "image.png", + byteSize: 128, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe({ draftKey }: { draftKey: string }) { + latest = useAgentInputDraft({ + draftKey, + composer: { + initialServerId: "host-1", + initialValues: { workingDir: "/repo" }, + isVisible: true, + onlineServerIds: ["host-1"], + lockedWorkingDir: "/repo", + }, + }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + let root: Root | null = createRoot(container); + await act(async () => { + root!.render( + + + , + ); + }); + + expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex"); + expect(getLatest().composerState?.commandDraftConfig).toEqual({ + provider: "codex", + cwd: "/repo", + modeId: "auto", + }); + + await act(async () => { + getLatest().setText("hello world"); + getLatest().setAttachments([{ kind: "image", metadata: image }]); + }); + + await act(async () => { + root!.unmount(); + }); + + root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().text).toBe("hello world"); + expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); + }); + + it("migrates legacy image drafts to image attachments on hydration", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "legacy-image", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/legacy-image", + createdAt: 10, + fileName: "legacy.png", + byteSize: 512, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:legacy": { + input: { + text: "legacy text", + images: [image], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + } as unknown as DraftRecordForTest, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:legacy", initialCwd: "/repo" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().text).toBe("legacy text"); + expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); + expect(getLatest().cwd).toBe("/repo"); + expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({ + text: "legacy text", + attachments: [{ kind: "image", metadata: image }], + cwd: "/repo", + }); + }); + + it("round-trips persisted attachments and cwd unchanged", async () => { + let latest: ReturnType | null = null; + const githubIssue: ComposerAttachment = { + kind: "github_issue", + item: { + kind: "issue", + number: 42, + title: "Unify attachments", + url: "https://github.com/paseo/paseo/issues/42", + state: "open", + body: "body", + labels: ["composer"], + }, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:new-shape": { + input: { + text: "new text", + attachments: [githubIssue], + cwd: "/persisted", + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + }, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:new-shape", initialCwd: "/initial" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().text).toBe("new text"); + expect(getLatest().attachments).toEqual([githubIssue]); + expect(getLatest().cwd).toBe("/persisted"); + + await act(async () => { + root.unmount(); + }); + + expect(useDraftStore.getState().drafts["draft:new-shape"]?.input).toEqual({ + text: "new text", + attachments: [githubIssue], + cwd: "/persisted", + }); + }); + + it("seeds cwd from initialCwd when persisted cwd is missing", async () => { + let latest: ReturnType | null = null; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:seed-cwd": { + input: { + text: "seed", + attachments: [], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + } as unknown as DraftRecordForTest, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:seed-cwd", initialCwd: "/initial" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().cwd).toBe("/initial"); + expect(useDraftStore.getState().drafts["draft:seed-cwd"]?.input).toEqual({ + text: "seed", + attachments: [], + cwd: "/initial", + }); + }); + + it("seeds cwd as empty string when persisted cwd and initialCwd are missing", async () => { + let latest: ReturnType | null = null; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:empty-cwd": { + input: { + text: "seed", + attachments: [], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + } as unknown as DraftRecordForTest, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:empty-cwd" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().cwd).toBe(""); + expect(useDraftStore.getState().drafts["draft:empty-cwd"]?.input).toEqual({ + text: "seed", + attachments: [], + cwd: "", + }); + }); + + it("updates and persists attachments through setAttachments", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "next-image", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/next-image", + createdAt: 11, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:attachments", initialCwd: "/repo" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + getLatest().setText("with attachment"); + getLatest().setAttachments([{ kind: "image", metadata: image }]); + }); + + expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); + expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({ + text: "with attachment", + attachments: [{ kind: "image", metadata: image }], + cwd: "/repo", + }); + }); + + it("clear resets text, attachments, and cwd", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "clear-image", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/clear-image", + createdAt: 12, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:clear", initialCwd: "/repo" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + getLatest().setText("queued message"); + getLatest().setAttachments([{ kind: "image", metadata: image }]); + getLatest().setCwd("/changed"); + }); + + await act(async () => { + getLatest().clear("sent"); + }); + + expect(getLatest().text).toBe(""); + expect(getLatest().attachments).toEqual([]); + expect(getLatest().cwd).toBe(""); + expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({ + text: "", + attachments: [], + cwd: "", + }); + }); + + it("clears drafts with sent and abandoned lifecycle tombstones", async () => { + let latest: ReturnType | null = null; + const sentImage: AttachmentMetadata = { + id: "attachment-sent", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/sent", + createdAt: 2, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:lifecycle" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + getLatest().setText("queued message"); + getLatest().setAttachments([{ kind: "image", metadata: sentImage }]); + }); + + await act(async () => { + getLatest().clear("sent"); + }); + + expect(getLatest().text).toBe(""); + expect(getLatest().attachments).toEqual([]); + expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ + lifecycle: "sent", + input: { text: "", attachments: [], cwd: "" }, + }); + + await act(async () => { + getLatest().setText("draft again"); + }); + + await act(async () => { + getLatest().clear("abandoned"); + }); + + expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ + lifecycle: "abandoned", + input: { text: "", attachments: [], cwd: "" }, + }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-input-draft.test.ts b/packages/app/src/hooks/use-agent-input-draft.test.ts new file mode 100644 index 000000000..b766b0ffd --- /dev/null +++ b/packages/app/src/hooks/use-agent-input-draft.test.ts @@ -0,0 +1,194 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined, + }, +})); + +vi.mock("@/attachments/service", () => ({ + garbageCollectAttachments: async () => undefined, +})); + +vi.mock("./use-agent-form-state", () => ({ + useAgentFormState: () => ({ + selectedServerId: "host-1", + setSelectedServerId: () => undefined, + setSelectedServerIdFromUser: () => undefined, + selectedProvider: "codex", + setProviderFromUser: () => undefined, + selectedMode: "auto", + setModeFromUser: () => undefined, + selectedModel: "", + setModelFromUser: () => undefined, + selectedThinkingOptionId: "", + setThinkingOptionFromUser: () => undefined, + workingDir: "/repo", + setWorkingDir: () => undefined, + setWorkingDirFromUser: () => undefined, + providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }], + providerDefinitionMap: new Map(), + agentDefinition: undefined, + modeOptions: [{ id: "auto", label: "Auto" }], + availableModels: [], + allProviderModels: new Map(), + isAllModelsLoading: false, + availableThinkingOptions: [], + isModelLoading: false, + modelError: null, + refreshProviderModels: () => undefined, + setProviderAndModelFromUser: () => undefined, + workingDirIsEmpty: false, + persistFormPreferences: async () => undefined, + }), +})); + +let __private__: typeof import("./use-agent-input-draft").__private__; + +beforeAll(async () => { + const storage = new Map(); + Object.defineProperty(globalThis, "window", { + value: { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + }, + }, + configurable: true, + }); + + ({ __private__ } = await import("./use-agent-input-draft")); +}); + +describe("useAgentInputDraft", () => { + describe("__private__.resolveDraftKey", () => { + it("returns an object draft key string unchanged", () => { + expect( + __private__.resolveDraftKey({ + draftKey: "draft:key", + selectedServerId: "host-1", + }), + ).toBe("draft:key"); + }); + + it("resolves a computed draft key from the selected server", () => { + expect( + __private__.resolveDraftKey({ + draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`, + selectedServerId: "host-1", + }), + ).toBe("draft:host-1"); + }); + }); + + describe("__private__.resolveEffectiveComposerModelId", () => { + const models = [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + }, + { + provider: "codex", + id: "gpt-5.4-mini", + label: "gpt-5.4-mini", + }, + ]; + + it("prefers the selected model when present", () => { + expect( + __private__.resolveEffectiveComposerModelId({ + selectedModel: "gpt-5.4-mini", + availableModels: models, + }), + ).toBe("gpt-5.4-mini"); + }); + + it("returns empty string when no model selected", () => { + expect( + __private__.resolveEffectiveComposerModelId({ + selectedModel: "", + availableModels: models, + }), + ).toBe(""); + }); + }); + + describe("__private__.resolveEffectiveComposerThinkingOptionId", () => { + const models = [ + { + provider: "codex", + id: "gpt-5.4", + label: "gpt-5.4", + isDefault: true, + defaultThinkingOptionId: "high", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + ], + }, + ]; + + it("prefers the selected thinking option when present", () => { + expect( + __private__.resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: "medium", + availableModels: models, + effectiveModelId: "gpt-5.4", + }), + ).toBe("medium"); + }); + + it("falls back to the model default thinking option", () => { + expect( + __private__.resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: "", + availableModels: models, + effectiveModelId: "gpt-5.4", + }), + ).toBe("high"); + }); + }); + + describe("__private__.buildDraftComposerCommandConfig", () => { + it("returns undefined when cwd is empty", () => { + expect( + __private__.buildDraftComposerCommandConfig({ + provider: "codex", + cwd: " ", + modeOptions: [], + selectedMode: "", + effectiveModelId: "gpt-5.4", + effectiveThinkingOptionId: "high", + }), + ).toBeUndefined(); + }); + + it("builds the draft command config from derived composer state", () => { + expect( + __private__.buildDraftComposerCommandConfig({ + provider: "codex", + cwd: "/repo", + modeOptions: [{ id: "auto", label: "Auto" }], + selectedMode: "auto", + effectiveModelId: "gpt-5.4", + effectiveThinkingOptionId: "high", + }), + ).toEqual({ + provider: "codex", + cwd: "/repo", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "high", + }); + }); + }); +}); diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 200474970..b75d4cacf 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -1,52 +1,204 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import type { AttachmentMetadata } from "@/attachments/types"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { ComposerAttachment } from "@/attachments/types"; +import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar"; +import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import { + useAgentFormState, + type CreateAgentInitialValues, + type UseAgentFormStateResult, +} from "@/hooks/use-agent-form-state"; +import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useDraftStore } from "@/stores/draft-store"; +import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; -type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]); +type AttachmentUpdater = + | ComposerAttachment[] + | ((prev: ComposerAttachment[]) => ComposerAttachment[]); + +type AgentInputDraftComposerOptions = { + initialServerId: string | null; + initialValues?: CreateAgentInitialValues; + isVisible?: boolean; + onlineServerIds?: string[]; + lockedWorkingDir?: string; +}; + +type DraftKeyContext = { + selectedServerId: string | null; +}; + +type DraftKeyInput = string | ((context: DraftKeyContext) => string); + +type UseAgentInputDraftInput = { + draftKey: DraftKeyInput; + initialCwd?: string; + composer?: AgentInputDraftComposerOptions; +}; + +type DraftComposerState = UseAgentFormStateResult & { + workingDir: string; + effectiveModelId: string; + effectiveThinkingOptionId: string; + featureValues: Record | undefined; + statusControls: DraftAgentStatusBarProps; + commandDraftConfig: DraftCommandConfig | undefined; +}; interface AgentInputDraft { text: string; setText: (text: string) => void; - images: AttachmentMetadata[]; - setImages: (updater: ImageUpdater) => void; + attachments: ComposerAttachment[]; + setAttachments: (updater: AttachmentUpdater) => void; + cwd: string; + setCwd: (cwd: string) => void; clear: (lifecycle: "sent" | "abandoned") => void; isHydrated: boolean; + composerState: DraftComposerState | null; } -function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean { - return input.text.trim().length > 0 || input.images.length > 0; +function hasDraftContent(input: { + text: string; + attachments: ComposerAttachment[]; + cwd: string; +}): boolean { + return ( + input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0 + ); } -function areImagesEqual(input: { - left: AttachmentMetadata[]; - right: AttachmentMetadata[]; +function areAttachmentsEqual(input: { + left: ComposerAttachment[]; + right: ComposerAttachment[]; }): boolean { if (input.left.length !== input.right.length) { return false; } - return input.left.every((image, index) => { + return input.left.every((attachment, index) => { const other = input.right[index]; - return ( - image.id === other?.id && - image.mimeType === other?.mimeType && - image.storageType === other?.storageType && - image.storageKey === other?.storageKey - ); + return JSON.stringify(attachment) === JSON.stringify(other); }); } -export function useAgentInputDraft(draftKey: string): AgentInputDraft { +function resolveDraftKey(input: { + draftKey: DraftKeyInput; + selectedServerId: string | null; +}): string { + if (typeof input.draftKey === "function") { + return input.draftKey({ selectedServerId: input.selectedServerId }); + } + return input.draftKey; +} + +function resolveEffectiveComposerModelId(input: { + selectedModel: string; + availableModels: AgentModelDefinition[]; +}): string { + return input.selectedModel.trim(); +} + +function resolveEffectiveComposerThinkingOptionId(input: { + selectedThinkingOptionId: string; + availableModels: AgentModelDefinition[]; + effectiveModelId: string; +}): string { + const selectedThinkingOptionId = input.selectedThinkingOptionId.trim(); + if (selectedThinkingOptionId) { + return selectedThinkingOptionId; + } + + const selectedModelDefinition = + input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null; + return selectedModelDefinition?.defaultThinkingOptionId ?? ""; +} + +function buildDraftComposerCommandConfig(input: { + provider: DraftAgentStatusBarProps["selectedProvider"]; + cwd: string; + modeOptions: DraftAgentStatusBarProps["modeOptions"]; + selectedMode: string; + effectiveModelId: string; + effectiveThinkingOptionId: string; + featureValues?: Record; +}): DraftCommandConfig | undefined { + const cwd = input.cwd.trim(); + if (!cwd) { + return undefined; + } + + return { + provider: input.provider, + cwd, + ...(input.modeOptions.length > 0 && input.selectedMode !== "" + ? { modeId: input.selectedMode } + : {}), + ...(input.effectiveModelId ? { model: input.effectiveModelId } : {}), + ...(input.effectiveThinkingOptionId + ? { thinkingOptionId: input.effectiveThinkingOptionId } + : {}), + ...(input.featureValues ? { featureValues: input.featureValues } : {}), + }; +} + +function buildDraftStatusControls(input: { + formState: UseAgentFormStateResult; + features?: DraftAgentStatusBarProps["features"]; + onSetFeature?: DraftAgentStatusBarProps["onSetFeature"]; + onDropdownClose?: DraftAgentStatusBarProps["onDropdownClose"]; +}): DraftAgentStatusBarProps { + const { formState, features, onSetFeature, onDropdownClose } = input; + return { + providerDefinitions: formState.providerDefinitions, + selectedProvider: formState.selectedProvider, + onSelectProvider: formState.setProviderFromUser, + modeOptions: formState.modeOptions, + selectedMode: formState.selectedMode, + onSelectMode: formState.setModeFromUser, + models: formState.availableModels, + selectedModel: formState.selectedModel, + onSelectModel: formState.setModelFromUser, + isModelLoading: formState.isModelLoading, + allProviderModels: formState.allProviderModels, + isAllModelsLoading: formState.isAllModelsLoading, + onSelectProviderAndModel: formState.setProviderAndModelFromUser, + thinkingOptions: formState.availableThinkingOptions, + selectedThinkingOptionId: formState.selectedThinkingOptionId, + onSelectThinkingOption: formState.setThinkingOptionFromUser, + features, + onSetFeature, + onDropdownClose, + onModelSelectorOpen: formState.refetchProviderModelsIfStale, + }; +} + +export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft { + const composerOptions = input.composer ?? null; + const formState = useAgentFormState({ + initialServerId: composerOptions?.initialServerId ?? null, + initialValues: composerOptions?.initialValues, + isVisible: composerOptions?.isVisible ?? false, + isCreateFlow: true, + onlineServerIds: composerOptions?.onlineServerIds ?? [], + }); + const draftKey = useMemo( + () => + resolveDraftKey({ + draftKey: input.draftKey, + selectedServerId: formState.selectedServerId, + }), + [formState.selectedServerId, input.draftKey], + ); const [text, setText] = useState(""); - const [images, setImagesState] = useState([]); + const [attachments, setAttachmentsState] = useState([]); + const [cwd, setCwd] = useState(input.initialCwd ?? ""); const [isHydrated, setIsHydrated] = useState(false); const draftGenerationRef = useRef(0); const hydratedGenerationRef = useRef(0); - const setImages = useCallback((updater: ImageUpdater) => { - setImagesState((previousImages) => { + const setAttachments = useCallback((updater: AttachmentUpdater) => { + setAttachmentsState((previousAttachments) => { if (typeof updater === "function") { - return updater(previousImages); + return updater(previousAttachments); } return updater; }); @@ -62,7 +214,8 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { hydratedGenerationRef.current = generation; setText(""); - setImagesState([]); + setAttachmentsState([]); + setCwd(""); setIsHydrated(true); }, [draftKey], @@ -75,13 +228,17 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { hydratedGenerationRef.current = 0; setText(""); - setImagesState([]); + setAttachmentsState([]); + setCwd(input.initialCwd ?? ""); setIsHydrated(false); let cancelled = false; void (async () => { - const draft = await store.hydrateDraftInput(draftKey); + const draft = await store.hydrateDraftInput({ + draftKey, + initialCwd: input.initialCwd, + }); if (cancelled) { return; } @@ -91,7 +248,8 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { if (draft) { setText(draft.text); - setImagesState(draft.images); + setAttachmentsState(draft.attachments); + setCwd(draft.cwd); } hydratedGenerationRef.current = generation; @@ -101,7 +259,7 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { return () => { cancelled = true; }; - }, [draftKey]); + }, [draftKey, input.initialCwd]); useEffect(() => { const currentGeneration = draftGenerationRef.current; @@ -123,16 +281,18 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { const existing = store.getDraftInput(draftKey); const isSameDraft = - existing?.text === text && - areImagesEqual({ - left: existing?.images ?? [], - right: images, + existing !== undefined && + existing.text === text && + existing.cwd === cwd && + areAttachmentsEqual({ + left: existing.attachments, + right: attachments, }); if (isSameDraft) { return; } - if (!hasDraftContent({ text, images })) { + if (!hasDraftContent({ text, attachments, cwd })) { if (existing) { store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); } @@ -143,17 +303,128 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft { draftKey, draft: { text, - images, + attachments, + cwd, }, }); - }, [draftKey, images, text]); + }, [attachments, cwd, draftKey, text]); + + const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; + useEffect(() => { + if (!composerOptions || !lockedWorkingDir) { + return; + } + if (formState.workingDir.trim() === lockedWorkingDir) { + return; + } + formState.setWorkingDir(lockedWorkingDir); + }, [composerOptions, formState, lockedWorkingDir]); + + const effectiveModelId = useMemo( + () => + resolveEffectiveComposerModelId({ + selectedModel: formState.selectedModel, + availableModels: formState.availableModels, + }), + [formState.availableModels, formState.selectedModel], + ); + + const effectiveThinkingOptionId = useMemo( + () => + resolveEffectiveComposerThinkingOptionId({ + selectedThinkingOptionId: formState.selectedThinkingOptionId, + availableModels: formState.availableModels, + effectiveModelId, + }), + [effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId], + ); + + const workingDir = lockedWorkingDir || formState.workingDir; + const { + features: draftFeatures, + featureValues: draftFeatureValues, + setFeatureValue: setDraftFeatureValue, + } = useDraftAgentFeatures({ + serverId: formState.selectedServerId, + provider: formState.selectedProvider, + cwd: workingDir, + modeId: formState.selectedMode, + modelId: effectiveModelId, + thinkingOptionId: effectiveThinkingOptionId, + }); + + const commandDraftConfig = useMemo( + () => + composerOptions + ? buildDraftComposerCommandConfig({ + provider: formState.selectedProvider, + cwd: workingDir, + modeOptions: formState.modeOptions, + selectedMode: formState.selectedMode, + effectiveModelId, + effectiveThinkingOptionId, + featureValues: draftFeatureValues, + }) + : undefined, + [ + composerOptions, + effectiveModelId, + effectiveThinkingOptionId, + draftFeatureValues, + workingDir, + formState.modeOptions, + formState.selectedMode, + formState.selectedProvider, + ], + ); + + const composerState = useMemo(() => { + if (!composerOptions) { + return null; + } + + return { + ...formState, + workingDir, + effectiveModelId, + effectiveThinkingOptionId, + featureValues: draftFeatureValues, + statusControls: buildDraftStatusControls({ + formState, + features: draftFeatures, + onSetFeature: setDraftFeatureValue, + }), + commandDraftConfig, + }; + }, [ + commandDraftConfig, + composerOptions, + effectiveModelId, + effectiveThinkingOptionId, + draftFeatures, + draftFeatureValues, + formState, + setDraftFeatureValue, + workingDir, + ]); return { text, setText, - images, - setImages, + attachments, + setAttachments, + cwd, + setCwd, clear, isHydrated, + composerState, }; } + +export const __private__ = { + resolveDraftKey, + resolveEffectiveComposerModelId, + resolveEffectiveComposerThinkingOptionId, + buildDraftComposerCommandConfig, + buildDraftStatusControls, +}; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 5bc29306e..e9b33d76f 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -5,6 +5,7 @@ export interface AgentScreenAgent { id: string; status: "initializing" | "idle" | "running" | "error" | "closed"; cwd: string; + lastError?: string | null; projectPlacement?: { checkout?: { cwd?: string; diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index ee23ecee0..1bd2de9cb 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -132,4 +132,26 @@ describe("useArchiveAgent", () => { entries: [{ agent: { id: "agent-2" } }], }); }); + + it("can apply archived agent close results without invalidating cached lists", () => { + const queryClient = new QueryClient(); + useSessionStore.getState().initializeSession("server-a", {} as DaemonClient); + useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]])); + queryClient.setQueryData(["sidebarAgentsList", "server-a"], { + entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], + }); + queryClient.setQueryData(["allAgents", "server-a"], { + entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], + }); + + applyArchivedAgentCloseResults({ + queryClient, + serverId: "server-a", + results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }], + invalidateQueries: false, + }); + + expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false); + }); }); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 9444ed887..5fc56a78c 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -29,6 +29,16 @@ interface AgentsListQueryData { entries?: Array<{ agent?: { id?: string | null } | null } | null>; } +interface ArchivedAgentListCacheSnapshot { + sidebarAgentsList: AgentsListQueryData | undefined; + allAgents: AgentsListQueryData | undefined; +} + +interface ArchiveAgentMutationContext { + agent: ReturnType; + lists: ArchivedAgentListCacheSnapshot; +} + function toArchiveKey(input: ArchiveAgentInput): string { const serverId = input.serverId.trim(); const agentId = input.agentId.trim(); @@ -111,6 +121,74 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen ); } +function getStoredAgentSnapshot(input: ArchiveAgentInput) { + return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId); +} + +function restoreAgentSnapshot( + input: ArchiveAgentInput & { agent: ReturnType }, +): void { + const setAgents = useSessionStore.getState().setAgents; + setAgents(input.serverId, (prev) => { + const hasAgent = prev.has(input.agentId); + if (!input.agent) { + if (!hasAgent) { + return prev; + } + const next = new Map(prev); + next.delete(input.agentId); + return next; + } + + const current = prev.get(input.agentId); + if (current === input.agent) { + return prev; + } + + const next = new Map(prev); + next.set(input.agentId, input.agent); + return next; + }); +} + +function getArchivedAgentListCacheSnapshot( + queryClient: QueryClient, + serverId: string, +): ArchivedAgentListCacheSnapshot { + return { + sidebarAgentsList: queryClient.getQueryData([ + "sidebarAgentsList", + serverId, + ]), + allAgents: queryClient.getQueryData(["allAgents", serverId]), + }; +} + +function restoreCachedListSnapshot( + queryClient: QueryClient, + queryKey: readonly [string, string], + snapshot: AgentsListQueryData | undefined, +): void { + if (snapshot === undefined) { + queryClient.removeQueries({ queryKey, exact: true }); + return; + } + queryClient.setQueryData(queryKey, snapshot); +} + +function restoreArchivedAgentListCacheSnapshot( + queryClient: QueryClient, + serverId: string, + snapshot: ArchivedAgentListCacheSnapshot, +): void { + restoreCachedListSnapshot( + queryClient, + ["sidebarAgentsList", serverId], + snapshot.sidebarAgentsList, + ); + restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents); +} + function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void { const archivedAt = new Date(input.archivedAt); if (Number.isNaN(archivedAt.getTime())) { @@ -139,6 +217,7 @@ interface ApplyArchivedAgentCloseResultsInput { queryClient: QueryClient; serverId: string; results: ArchivedAgentCloseResult[]; + invalidateQueries?: boolean; } export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseResultsInput): void { @@ -158,12 +237,14 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes }); } - void input.queryClient.invalidateQueries({ - queryKey: ["sidebarAgentsList", input.serverId], - }); - void input.queryClient.invalidateQueries({ - queryKey: ["allAgents", input.serverId], - }); + if (input.invalidateQueries ?? true) { + void input.queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsList", input.serverId], + }); + void input.queryClient.invalidateQueries({ + queryKey: ["allAgents", input.serverId], + }); + } } export function clearArchiveAgentPending(input: IsAgentArchivingInput): void { @@ -193,26 +274,56 @@ export function useArchiveAgent() { return await client.archiveAgent(input.agentId); }, onMutate: (input) => { + const context: ArchiveAgentMutationContext = { + agent: getStoredAgentSnapshot(input), + lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId), + }; + const archivedAt = new Date().toISOString(); + + applyArchivedAgentCloseResults({ + queryClient, + serverId: input.serverId, + results: [{ agentId: input.agentId, archivedAt }], + invalidateQueries: false, + }); setAgentArchiving({ queryClient, serverId: input.serverId, agentId: input.agentId, isArchiving: true, }); + return context; }, onSuccess: (result, input) => { - applyArchivedAgentCloseResults({ - queryClient, + markAgentArchivedInStore({ serverId: input.serverId, - results: [{ agentId: input.agentId, archivedAt: result.archivedAt }], + agentId: input.agentId, + archivedAt: result.archivedAt, }); }, + onError: (_error, input, context) => { + if (!context) { + return; + } + restoreAgentSnapshot({ + serverId: input.serverId, + agentId: input.agentId, + agent: context.agent, + }); + restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists); + }, onSettled: (_result, _error, input) => { clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: input.agentId, }); + void queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsList", input.serverId], + }); + void queryClient.invalidateQueries({ + queryKey: ["allAgents", input.serverId], + }); }, }); diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index 17a446ede..801b1033a 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -20,6 +20,9 @@ export interface PrHint { url: string; number: number; state: "open" | "merged" | "closed"; + checks?: Array<{ name: string; status: string; url: string | null }>; + checksStatus?: "none" | "pending" | "success" | "failure"; + reviewDecision?: "approved" | "changes_requested" | "pending" | null; } function parsePullRequestNumber(url: string): number | null { @@ -57,6 +60,9 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null : status.state === "open" ? "open" : "closed", + checks: status.checks, + checksStatus: status.checksStatus as PrHint["checksStatus"], + reviewDecision: status.reviewDecision as PrHint["reviewDecision"], }; } diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 92abbaa12..6f2bd9a00 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TextInput } from "react-native"; import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useSessionStore } from "@/stores/session-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useHosts } from "@/runtime/host-runtime"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; @@ -11,13 +12,18 @@ import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; -import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes"; +import { + buildHostAgentDetailRoute, + buildSettingsRoute, + parseServerIdFromPathname, +} from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string"; import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { getIsElectronRuntime } from "@/constants/layout"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { focusWithRetries } from "@/utils/web-focus"; @@ -164,9 +170,8 @@ export function useCommandCenter() { }, [agents, open, query]); const settingsRoute = useMemo(() => { - const serverIdFromPath = activeServerId; - return serverIdFromPath ? buildHostSettingsRoute(serverIdFromPath) : "/"; - }, [activeServerId]); + return buildSettingsRoute(); + }, []); const actionItems = useMemo(() => { if (!open) { @@ -215,9 +220,17 @@ export function useCommandCenter() { // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); setOpen(false); + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(), + workspaceDirectory: agent.cwd, + }); + if (!workspaceId) { + router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any); + return; + } const route = prepareWorkspaceTab({ serverId: agent.serverId, - workspaceId: agent.cwd, + workspaceId, target: { kind: "agent", agentId: agent.id }, }); router.navigate(route); diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts index 98d55b74f..a346818bd 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -1,10 +1,13 @@ import { useCallback, useMemo, useReducer } from "react"; +import type { ComposerAttachment } from "@/attachments/types"; +import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; +import type { AgentAttachment } from "@server/shared/messages"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -60,18 +63,22 @@ type CreateRequestResult = { type SubmitContext = { text: string; - images?: UserMessageImageAttachment[]; + attachments: ComposerAttachment[]; + cwd: string; }; type CreateRequestContext = { attempt: CreateAttempt; text: string; images?: UserMessageImageAttachment[]; + attachments?: AgentAttachment[]; + cwd: string; }; interface UseDraftAgentCreateFlowOptions { draftId: string; getPendingServerId: () => string | null; + allowEmptyText?: boolean; validateBeforeSubmit?: (ctx: SubmitContext) => string | null; onBeforeSubmit?: (ctx: CreateRequestContext) => void; onCreateStart?: () => void; @@ -84,6 +91,7 @@ interface UseDraftAgentCreateFlowOptions { export function useDraftAgentCreateFlow({ draftId, getPendingServerId, + allowEmptyText = false, validateBeforeSubmit, onBeforeSubmit, onCreateStart, @@ -110,6 +118,10 @@ export function useDraftAgentCreateFlow({ return EMPTY_STREAM_ITEMS; } + if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) { + return EMPTY_STREAM_ITEMS; + } + return [ { kind: "user_message", @@ -131,21 +143,27 @@ export function useDraftAgentCreateFlow({ }, [buildDraftAgent, machine]); const handleCreateFromInput = useCallback( - async ({ text, images }: SubmitContext) => { + async ({ text, attachments, cwd }: SubmitContext) => { if (isSubmitting) { throw new Error("Already loading"); } dispatch({ type: "DRAFT_SET_ERROR", message: "" }); + const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const images = wirePayload.images; const trimmedPrompt = text.trim(); - if (!trimmedPrompt) { + if (!trimmedPrompt && !allowEmptyText) { const error = new Error("Initial prompt is required"); dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; } - const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images }); + const validationError = validateBeforeSubmit?.({ + text: trimmedPrompt, + attachments, + cwd, + }); if (validationError) { const error = new Error(validationError); dispatch({ type: "DRAFT_SET_ERROR", message: validationError }); @@ -176,7 +194,13 @@ export function useDraftAgentCreateFlow({ ...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}), }); - onBeforeSubmit?.({ attempt, text: trimmedPrompt, images }); + onBeforeSubmit?.({ + attempt, + text: trimmedPrompt, + images, + attachments: wirePayload.attachments, + cwd, + }); dispatch({ type: "SUBMIT", attempt }); onCreateStart?.(); @@ -185,6 +209,8 @@ export function useDraftAgentCreateFlow({ attempt, text: trimmedPrompt, images, + attachments: wirePayload.attachments, + cwd, }); if (createResult.agentId) { @@ -215,6 +241,7 @@ export function useDraftAgentCreateFlow({ setPendingCreateAttempt, updatePendingAgentId, validateBeforeSubmit, + allowEmptyText, ], ); diff --git a/packages/app/src/hooks/use-hover-safe-zone.ts b/packages/app/src/hooks/use-hover-safe-zone.ts new file mode 100644 index 000000000..6b3e27351 --- /dev/null +++ b/packages/app/src/hooks/use-hover-safe-zone.ts @@ -0,0 +1,81 @@ +import { useEffect, type RefObject } from "react"; +import type { View } from "react-native"; +import { isWeb } from "@/constants/platform"; + +interface UseHoverSafeZoneParams { + enabled: boolean; + triggerRef: RefObject; + contentRef: RefObject; + onEnterSafeZone: () => void; + onLeaveSafeZone: () => void; +} + +interface RectLike { + left: number; + right: number; + top: number; + bottom: number; +} + +// Tracks the pointer's position relative to a hover card's "safe zone": the +// trigger, the content, and the rectangular bridge between them. The bridge +// lets the pointer cross the visual gap without dropping the hover. Fires +// `onEnterSafeZone` / `onLeaveSafeZone` on transitions only. Web-only; no-op +// on native. +export function useHoverSafeZone({ + enabled, + triggerRef, + contentRef, + onEnterSafeZone, + onLeaveSafeZone, +}: UseHoverSafeZoneParams): void { + useEffect(() => { + if (!isWeb || !enabled) return; + + // The pointer opened the card, so we start inside. + let wasInside = true; + + function handlePointerMove(event: PointerEvent) { + const triggerNode = triggerRef.current as unknown as Element | null; + const contentNode = contentRef.current as unknown as Element | null; + const triggerRect = triggerNode ? triggerNode.getBoundingClientRect() : null; + const contentRect = contentNode ? contentNode.getBoundingClientRect() : null; + + const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY); + if (inside === wasInside) return; + wasInside = inside; + if (inside) onEnterSafeZone(); + else onLeaveSafeZone(); + } + + document.addEventListener("pointermove", handlePointerMove); + return () => { + document.removeEventListener("pointermove", handlePointerMove); + }; + }, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]); +} + +function isInsideRect(rect: RectLike | null, x: number, y: number): boolean { + if (!rect) return false; + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +function isInsideSafeZone( + trigger: RectLike | null, + content: RectLike | null, + x: number, + y: number, +): boolean { + if (isInsideRect(trigger, x, y)) return true; + if (isInsideRect(content, x, y)) return true; + if (!trigger || !content) return false; + + // Bridge: the horizontal strip connecting trigger and content, stretched + // vertically to span both. If they overlap horizontally there's no bridge. + const bridgeLeft = Math.min(trigger.right, content.right); + const bridgeRight = Math.max(trigger.left, content.left); + if (bridgeLeft >= bridgeRight) return false; + const bridgeTop = Math.min(trigger.top, content.top); + const bridgeBottom = Math.max(trigger.bottom, content.bottom); + return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom; +} diff --git a/packages/app/src/hooks/use-image-attachment-picker.ts b/packages/app/src/hooks/use-image-attachment-picker.ts index 014ffa281..6808acae6 100644 --- a/packages/app/src/hooks/use-image-attachment-picker.ts +++ b/packages/app/src/hooks/use-image-attachment-picker.ts @@ -1,6 +1,5 @@ import { useCallback, useRef } from "react"; import { Alert } from "react-native"; -import { Platform } from "react-native"; import * as ImagePicker from "expo-image-picker"; import { isElectronRuntime } from "@/desktop/host"; import { diff --git a/packages/app/src/hooks/use-is-local-daemon.ts b/packages/app/src/hooks/use-is-local-daemon.ts index 2557779a3..76601739a 100644 --- a/packages/app/src/hooks/use-is-local-daemon.ts +++ b/packages/app/src/hooks/use-is-local-daemon.ts @@ -15,8 +15,7 @@ async function loadDesktopDaemonServerId(): Promise }; } -export function useIsLocalDaemon(serverId: string): boolean { - const normalizedServerId = serverId.trim(); +export function useLocalDaemonServerId(): string | null { const isDesktopApp = shouldUseDesktopDaemon(); const query = useQuery({ @@ -32,9 +31,20 @@ export function useIsLocalDaemon(serverId: string): boolean { retry: false, }); - if (!isDesktopApp || normalizedServerId.length === 0) { + if (!isDesktopApp) { + return null; + } + + return query.data?.serverId ?? null; +} + +export function useIsLocalDaemon(serverId: string): boolean { + const normalizedServerId = serverId.trim(); + const localServerId = useLocalDaemonServerId(); + + if (localServerId === null || normalizedServerId.length === 0) { return false; } - return query.data?.serverId === normalizedServerId; + return localServerId === normalizedServerId; } diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 84625295b..ddb203ee8 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -5,7 +5,7 @@ import { useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { - buildHostSettingsRoute, + buildSettingsRoute, parseHostAgentRouteFromPathname, parseServerIdFromPathname, parseHostWorkspaceRouteFromPathname, @@ -15,7 +15,6 @@ import { type MessageInputKeyboardActionKind, type KeyboardShortcutPayload, } from "@/keyboard/actions"; -import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { type ChordState, @@ -33,7 +32,6 @@ export function useKeyboardShortcuts({ enabled, isMobile, toggleAgentList, - selectedAgentId, toggleFileExplorer, toggleBothSidebars, toggleFocusMode, @@ -42,7 +40,6 @@ export function useKeyboardShortcuts({ enabled: boolean; isMobile: boolean; toggleAgentList: () => void; - selectedAgentId?: string; toggleFileExplorer?: () => void; toggleBothSidebars?: () => void; toggleFocusMode?: () => void; @@ -248,14 +245,11 @@ export function useKeyboardShortcuts({ toggleAgentList(); return true; case "settings.toggle": - if (pathname.endsWith("/settings")) { + if (pathname.startsWith("/settings")) { router.back(); return true; } - if (!activeServerId) { - return false; - } - router.push(buildHostSettingsRoute(activeServerId)); + router.push(buildSettingsRoute()); return true; case "sidebar.toggle.both": if (toggleBothSidebars) { @@ -263,19 +257,9 @@ export function useKeyboardShortcuts({ } return true; case "sidebar.toggle.right": - if (!toggleFileExplorer) { - return false; + if (toggleFileExplorer) { + toggleFileExplorer(); } - if ( - !canToggleFileExplorerShortcut({ - selectedAgentId, - pathname, - toggleFileExplorer, - }) - ) { - return false; - } - toggleFileExplorer(); return true; case "view.toggle.focus": if (toggleFocusMode) { @@ -360,11 +344,6 @@ export function useKeyboardShortcuts({ isDesktop: isDesktopApp, focusScope, commandCenterOpen: store.commandCenterOpen, - hasSelectedAgent: canToggleFileExplorerShortcut({ - selectedAgentId, - pathname, - toggleFileExplorer, - }), }, chordState: chordStateRef.current, onChordReset: () => { @@ -445,7 +424,6 @@ export function useKeyboardShortcuts({ openProjectPickerAction, pathname, resetModifiers, - selectedAgentId, toggleAgentList, toggleFileExplorer, toggleFocusMode, diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts new file mode 100644 index 000000000..bc365efc5 --- /dev/null +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +const { replaceRoute } = vi.hoisted(() => ({ + replaceRoute: vi.fn(), +})); + +vi.mock("expo-router", () => ({ + router: { + replace: replaceRoute, + }, +})); + +import { openProjectDirectly } from "@/hooks/use-open-project"; +import { useSessionStore } from "@/stores/session-store"; +import { + buildWorkspaceTabPersistenceKey, + collectAllTabs, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { generateDraftId } from "@/stores/draft-keys"; + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "/repo/project"; + +function createOpenDraftTab() { + return (workspaceKey: string) => + useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { + kind: "draft", + draftId: generateDraftId(), + }); +} + +describe("openProjectDirectly", () => { + beforeEach(() => { + replaceRoute.mockReset(); + useSessionStore.setState({ + sessions: {}, + }); + useSessionStore.getState().initializeSession(SERVER_ID, {} as never); + useWorkspaceLayoutStore.setState({ + layoutByWorkspace: {}, + splitSizesByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + }); + vi.restoreAllMocks(); + }); + + it("opens the workspace directly, marks workspaces hydrated, and seeds a draft tab", async () => { + const result = await openProjectDirectly({ + serverId: SERVER_ID, + projectPath: WORKSPACE_ID, + isConnected: true, + client: { + openProject: vi.fn(async () => ({ + requestId: "request-1", + error: null, + workspace: { + id: "1", + projectId: "1", + projectDisplayName: "project", + projectRootPath: WORKSPACE_ID, + workspaceDirectory: WORKSPACE_ID, + projectKind: "git" as const, + workspaceKind: "checkout" as const, + name: "project", + status: "done" as const, + activityAt: null, + diffStat: null, + scripts: [], + }, + })), + }, + mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, + setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, + openDraftTab: createOpenDraftTab(), + replaceRoute, + }); + + expect(result).toBe(true); + expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true); + expect( + Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []), + ).toEqual([ + expect.objectContaining({ + id: "1", + projectId: "1", + projectRootPath: WORKSPACE_ID, + workspaceDirectory: WORKSPACE_ID, + }), + ]); + + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: "1", + }); + expect(workspaceKey).toBeTruthy(); + const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string]; + expect(layout.root.kind).toBe("pane"); + const tabs = collectAllTabs(layout.root); + expect(tabs).toHaveLength(1); + expect(tabs[0]?.target.kind).toBe("draft"); + expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/1"); + }); + + it("does not navigate or seed tabs when openProject fails", async () => { + const result = await openProjectDirectly({ + serverId: SERVER_ID, + projectPath: WORKSPACE_ID, + isConnected: true, + client: { + openProject: vi.fn(async () => ({ + requestId: "request-2", + error: "Failed to open project", + workspace: null, + })), + }, + mergeWorkspaces: useSessionStore.getState().mergeWorkspaces, + setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces, + openDraftTab: createOpenDraftTab(), + replaceRoute, + }); + + expect(result).toBe(false); + expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false); + expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0); + expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({}); + expect(replaceRoute).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index ed353dccf..c9879d6e0 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,45 +1,85 @@ -import { router } from "expo-router"; import { useCallback } from "react"; -import { useToast } from "@/contexts/toast-context"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; -import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; +import { router } from "expo-router"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { + normalizeWorkspaceDescriptor, + type WorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; +import { + buildWorkspaceTabPersistenceKey, + useWorkspaceLayoutStore, +} from "@/stores/workspace-layout-store"; +import { generateDraftId } from "@/stores/draft-keys"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; + +interface OpenProjectDirectlyInput { + serverId: string; + projectPath: string; + isConnected: boolean; + client: Pick | null; + mergeWorkspaces: (serverId: string, workspaces: Iterable) => void; + setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; + openDraftTab: (workspaceKey: string) => string | null; + replaceRoute: (route: string) => void; +} + +export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise { + const normalizedServerId = input.serverId.trim(); + const trimmedPath = input.projectPath.trim(); + if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) { + return false; + } + + const payload = await input.client.openProject(trimmedPath); + if (payload.error || !payload.workspace) { + return false; + } + + const workspace = normalizeWorkspaceDescriptor(payload.workspace); + input.mergeWorkspaces(normalizedServerId, [workspace]); + input.setHasHydratedWorkspaces(normalizedServerId, true); + + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: normalizedServerId, + workspaceId: workspace.id, + }); + if (!workspaceKey) { + return false; + } + + input.openDraftTab(workspaceKey); + input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id)); + return true; +} export function useOpenProject(serverId: string | null): (path: string) => Promise { const normalizedServerId = serverId?.trim() ?? ""; - const toast = useToast(); const client = useHostRuntimeClient(normalizedServerId); + const isConnected = useHostRuntimeIsConnected(normalizedServerId); const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( async (path: string) => { - const trimmedPath = path.trim(); - if (!trimmedPath || !client || !normalizedServerId) { - return false; - } - - try { - const payload = await client.openProject(trimmedPath); - if (payload.error || !payload.workspace) { - throw new Error(payload.error || "Failed to open project"); - } - - mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]); - setHasHydratedWorkspaces(normalizedServerId, true); - router.replace( - prepareWorkspaceTab({ - serverId: normalizedServerId, - workspaceId: payload.workspace.id, - target: { kind: "draft", draftId: "new" }, - }) as any, - ); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : "Failed to open project"); - return false; - } + return openProjectDirectly({ + serverId: normalizedServerId, + projectPath: path, + isConnected, + client, + mergeWorkspaces, + setHasHydratedWorkspaces, + openDraftTab: (workspaceKey: string) => + useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, { + kind: "draft", + draftId: generateDraftId(), + }), + replaceRoute: (route) => { + router.replace(route as any); + }, + }); }, - [client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast], + [client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces], ); } diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index af3878a77..8edfbd8c1 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -6,8 +6,17 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { useSessionForServer } from "./use-session-directory"; import { queryClient as singletonQueryClient } from "@/query/query-client"; -export function providersSnapshotQueryKey(serverId: string | null) { - return ["providersSnapshot", serverId] as const; +function normalizeProvidersSnapshotCwdKey(cwd?: string | null): string | null { + const trimmed = cwd?.trim(); + if (!trimmed) { + return null; + } + + return trimmed.replace(/^\/(?:Users|home)\/[^/]+/, "~"); +} + +export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) { + return ["providersSnapshot", serverId, normalizeProvidersSnapshotCwdKey(cwd)] as const; } interface UseProvidersSnapshotResult { @@ -18,19 +27,27 @@ interface UseProvidersSnapshotResult { error: string | null; supportsSnapshot: boolean; refresh: (providers?: AgentProvider[]) => Promise; - invalidate: () => void; + refetchIfStale: () => void; } -export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult { +export function useProvidersSnapshot( + serverId: string | null, + cwd?: string | null, +): UseProvidersSnapshotResult { const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); + const normalizedCwd = cwd?.trim() || undefined; + const normalizedCwdKey = normalizeProvidersSnapshotCwdKey(normalizedCwd); const supportsSnapshot = useSessionForServer( serverId, (session) => session?.serverInfo?.features?.providersSnapshot === true, ); - const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]); + const queryKey = useMemo( + () => providersSnapshotQueryKey(serverId, normalizedCwdKey), + [normalizedCwdKey, serverId], + ); const snapshotQuery = useQuery({ queryKey, @@ -40,7 +57,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps if (!client) { throw new Error("Host is not connected"); } - return client.getProvidersSnapshot(); + return client.getProvidersSnapshot({ cwd: normalizedCwd }); }, }); @@ -49,7 +66,7 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps if (!client) { return; } - await client.refreshProvidersSnapshot({ providers }); + await client.refreshProvidersSnapshot({ cwd: normalizedCwd, providers }); }, }); const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation; @@ -63,13 +80,17 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps if (message.type !== "providers_snapshot_update") { return; } + const messageCwdKey = normalizeProvidersSnapshotCwdKey(message.payload.cwd); + if (messageCwdKey !== normalizedCwdKey) { + return; + } queryClient.setQueryData(queryKey, { entries: message.payload.entries, generatedAt: message.payload.generatedAt, requestId: "providers_snapshot_update", }); }); - }, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]); + }, [client, isConnected, normalizedCwdKey, queryClient, queryKey, serverId, supportsSnapshot]); const refresh = useCallback( async (providers?: AgentProvider[]) => { @@ -78,8 +99,8 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps [refreshSnapshot], ); - const invalidate = useCallback(() => { - void queryClient.invalidateQueries({ queryKey }); + const refetchIfStale = useCallback(() => { + void queryClient.refetchQueries({ queryKey, type: "active", stale: true }); }, [queryClient, queryKey]); return { @@ -90,15 +111,20 @@ export function useProvidersSnapshot(serverId: string | null): UseProvidersSnaps error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null, supportsSnapshot, refresh, - invalidate, + refetchIfStale, }; } -export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void { - const queryKey = providersSnapshotQueryKey(serverId); +export function prefetchProvidersSnapshot( + serverId: string, + client: DaemonClient, + cwd?: string | null, +): void { + const normalizedCwd = cwd?.trim() || undefined; + const queryKey = providersSnapshotQueryKey(serverId, normalizedCwd); void singletonQueryClient.prefetchQuery({ queryKey, staleTime: 60_000, - queryFn: () => client.getProvidersSnapshot(), + queryFn: () => client.getProvidersSnapshot({ cwd: normalizedCwd }), }); } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts index dd0f01ac4..40ae92b41 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { WorkspaceScriptPayload } from "@server/shared/messages"; import { appendMissingOrderKeys, applyStoredOrdering, @@ -19,7 +20,12 @@ function workspace( Partial< Pick< WorkspaceDescriptor, - "projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind" + | "projectDisplayName" + | "projectRootPath" + | "workspaceDirectory" + | "projectKind" + | "workspaceKind" + | "scripts" > >, ): WorkspaceDescriptor { @@ -28,14 +34,38 @@ function workspace( projectId: input.projectId, projectDisplayName: input.projectDisplayName ?? input.projectId, projectRootPath: input.projectRootPath ?? input.id, + workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id, projectKind: input.projectKind ?? "git", - workspaceKind: input.workspaceKind ?? "local_checkout", + workspaceKind: input.workspaceKind ?? "checkout", name: input.name, status: input.status, diffStat: null, + scripts: input.scripts ?? [], }; } +const runningScript: WorkspaceScriptPayload = { + scriptName: "web", + type: "service", + hostname: "web.paseo.localhost", + port: 3000, + proxyUrl: "http://web.paseo.localhost:6767", + lifecycle: "running", + health: "healthy", + exitCode: null, +}; + +const stoppedScript: WorkspaceScriptPayload = { + scriptName: "api", + type: "service", + hostname: "api.paseo.localhost", + port: 3001, + proxyUrl: "http://api.paseo.localhost:6767", + lifecycle: "stopped", + health: null, + exitCode: null, +}; + describe("applyStoredOrdering", () => { it("keeps unknown items on the baseline while applying stored order", () => { const result = applyStoredOrdering({ @@ -115,6 +145,26 @@ describe("buildSidebarProjectsFromWorkspaces", () => { expect(projects[0]?.workspaces[0]?.statusBucket).toBe("failed"); }); + it("threads scripts into workspace rows and derives hasRunningScripts", () => { + const projects = buildSidebarProjectsFromWorkspaces({ + serverId: "srv", + workspaces: [ + workspace({ + id: "/repo/main", + projectId: "project-1", + name: "main", + status: "running", + scripts: [runningScript, stoppedScript], + }), + ], + projectOrder: [], + workspaceOrderByScope: {}, + }); + + expect(projects[0]?.workspaces[0]?.scripts).toEqual([runningScript, stoppedScript]); + expect(projects[0]?.workspaces[0]?.hasRunningScripts).toBe(true); + }); + it("preserves stored project order even when input order differs", () => { const initialWorkspaces: WorkspaceDescriptor[] = [ workspace({ diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 327675b3d..d8e847e34 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,13 +1,13 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; import { mergeWorkspaceSnapshotWithExisting, normalizeWorkspaceDescriptor, useSessionStore, + type WorkspaceDescriptor, } from "@/stores/session-store"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import type { WorkspaceDescriptorPayload } from "@server/shared/messages"; import { projectDisplayNameFromProjectId } from "@/utils/project-display-name"; const EMPTY_ORDER: string[] = []; @@ -20,10 +20,15 @@ export interface SidebarWorkspaceEntry { workspaceKey: string; serverId: string; workspaceId: string; + projectRootPath?: string; + workspaceDirectory?: string; + projectKind: WorkspaceDescriptor["projectKind"]; workspaceKind: WorkspaceDescriptor["workspaceKind"]; name: string; statusBucket: SidebarStateBucket; diffStat: { additions: number; deletions: number } | null; + scripts: WorkspaceDescriptor["scripts"]; + hasRunningScripts: boolean; } export interface SidebarProjectEntry { @@ -75,7 +80,6 @@ function compareWorkspaceBaseline( } return left.workspaceId.localeCompare(right.workspaceId, undefined, { - numeric: true, sensitivity: "base", }); } @@ -103,7 +107,7 @@ export function buildSidebarProjectsFromWorkspaces(input: { projectName: workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId), projectKind: workspace.projectKind, - iconWorkingDir: workspace.projectRootPath || workspace.id, + iconWorkingDir: workspace.projectRootPath, statusBucket: "done", activeCount: 0, totalWorkspaces: 0, @@ -114,10 +118,15 @@ export function buildSidebarProjectsFromWorkspaces(input: { workspaceKey: `${input.serverId}:${workspace.id}`, serverId: input.serverId, workspaceId: workspace.id, + projectRootPath: workspace.projectRootPath, + workspaceDirectory: workspace.workspaceDirectory, + projectKind: workspace.projectKind, workspaceKind: workspace.workspaceKind, name: workspace.name, statusBucket: workspace.status, diffStat: workspace.diffStat, + scripts: workspace.scripts, + hasRunningScripts: workspace.scripts.some((script) => script.lifecycle === "running"), }; project.workspaces.push(row); diff --git a/packages/app/src/keyboard/keyboard-shortcuts.test.ts b/packages/app/src/keyboard/keyboard-shortcuts.test.ts index dd8c03970..38509393a 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.test.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.test.ts @@ -29,7 +29,6 @@ function shortcutContext( isDesktop: false, focusScope: "other", commandCenterOpen: false, - hasSelectedAgent: true, ...overrides, }; } @@ -477,10 +476,11 @@ describe("keyboard-shortcut help sections", () => { }, }, { - name: "uses mod+b as non-mac left sidebar shortcut", + name: "uses mod+b for the left sidebar and mod+period for both sidebars on non-mac", context: { isMac: false, isDesktop: false }, expectedKeys: { "toggle-left-sidebar": ["mod", "B"], + "toggle-both-sidebars": ["mod", "."], }, }, ]; diff --git a/packages/app/src/keyboard/keyboard-shortcuts.ts b/packages/app/src/keyboard/keyboard-shortcuts.ts index 94ae55ac9..8aac268d6 100644 --- a/packages/app/src/keyboard/keyboard-shortcuts.ts +++ b/packages/app/src/keyboard/keyboard-shortcuts.ts @@ -16,7 +16,6 @@ export type KeyboardShortcutContext = { isDesktop: boolean; focusScope: KeyboardFocusScope; commandCenterOpen: boolean; - hasSelectedAgent: boolean; }; export type KeyboardShortcutMatch = { @@ -57,8 +56,6 @@ interface ShortcutWhen { terminal?: false; /** false = disabled when command center is open */ commandCenter?: false; - /** true = requires a selected agent */ - hasSelectedAgent?: true; /** Exact focus scope match */ focusScope?: KeyboardFocusScope; } @@ -198,7 +195,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ help: { id: "workspace-tab-new", section: "tabs-panes", - label: "New agent tab", + label: "New tab", keys: ["mod", "T"], }, }, @@ -210,7 +207,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ help: { id: "workspace-tab-new", section: "tabs-panes", - label: "New agent tab", + label: "New tab", keys: ["mod", "T"], }, }, @@ -659,7 +656,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "sidebar-toggle-right-cmd-e-mac", action: "sidebar.toggle.right", combo: "Cmd+E", - when: { mac: true, hasSelectedAgent: true, commandCenter: false }, + when: { mac: true, commandCenter: false }, help: { id: "toggle-right-sidebar", section: "panels", @@ -671,7 +668,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "sidebar-toggle-right-ctrl-e-non-mac", action: "sidebar.toggle.right", combo: "Ctrl+E", - when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false }, + when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-right-sidebar", section: "panels", @@ -683,7 +680,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "sidebar-toggle-right-ctrl-backquote", action: "sidebar.toggle.right", combo: "Ctrl+`", - when: { hasSelectedAgent: true, commandCenter: false }, + when: { commandCenter: false }, }, // --- Toggle both sidebars --- @@ -743,7 +740,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "view-toggle-focus-cmd-shift-f-mac", action: "view.toggle.focus", combo: "Cmd+Shift+F", - when: { mac: true, hasSelectedAgent: true, commandCenter: false }, + when: { mac: true, commandCenter: false }, help: { id: "toggle-focus", section: "panels", @@ -755,7 +752,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [ id: "view-toggle-focus-ctrl-shift-f-non-mac", action: "view.toggle.focus", combo: "Ctrl+Shift+F", - when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false }, + when: { mac: false, commandCenter: false, terminal: false }, help: { id: "toggle-focus", section: "panels", @@ -1041,7 +1038,6 @@ function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutCo if (when.desktop !== undefined && when.desktop !== context.isDesktop) return false; if (when.terminal === false && context.focusScope === "terminal") return false; if (when.commandCenter === false && context.commandCenterOpen) return false; - if (when.hasSelectedAgent === true && !context.hasSelectedAgent) return false; if (when.focusScope !== undefined && context.focusScope !== when.focusScope) return false; return true; } diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index ba909e776..b0dee6e34 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -4,14 +4,13 @@ import ReanimatedAnimated from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { Bot } from "lucide-react-native"; import invariant from "tiny-invariant"; import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view"; -import { AgentInputArea } from "@/components/agent-input-area"; +import { Composer } from "@/components/composer"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { FileDropZone } from "@/components/file-drop-zone"; -import type { ImageAttachment } from "@/components/message-input"; import { getProviderIcon } from "@/components/provider-icons"; +import type { ImageAttachment } from "@/components/message-input"; import { ToastViewport, useToastHost } from "@/components/toast-host"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; @@ -93,7 +92,7 @@ function useAgentPanelDescriptor( ); const provider = descriptorState.provider; const label = resolveWorkspaceAgentTabLabel(descriptorState.title); - const icon = getProviderIcon(provider) ?? Bot; + const icon = getProviderIcon(provider); return { label: label ?? "", @@ -152,6 +151,12 @@ function isNotFoundErrorMessage(message: string): boolean { return /agent not found|not found/i.test(message); } +type AgentLookupState = + | { tag: "idle" } + | { tag: "loading" } + | { tag: "not_found"; message: string } + | { tag: "error"; message: string }; + function AgentPanelContent({ serverId, agentId, @@ -224,6 +229,195 @@ function AgentPanelBody({ isConnected: boolean; connectionStatus: HostRuntimeConnectionStatus; onOpenWorkspaceFile?: (input: { filePath: string }) => void; +}) { + const { theme } = useUnistyles(); + const { isArchivingAgent } = useArchiveAgent(); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const setAgents = useSessionStore((state) => state.setAgents); + const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); + const projectPlacement = useStoreWithEqualityFn( + useSessionStore, + (state) => + agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null, + (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), + ); + const agentState = useSessionStore( + useShallow((state) => { + const agent = agentId ? (state.sessions[serverId]?.agents?.get(agentId) ?? null) : null; + return { + serverId: agent?.serverId ?? null, + id: agent?.id ?? null, + status: agent?.status ?? null, + cwd: agent?.cwd ?? null, + lastError: agent?.lastError ?? null, + archivedAt: agent?.archivedAt ?? null, + }; + }), + ); + const [lookupState, setLookupState] = useState({ tag: "idle" }); + const lookupAttemptTokenRef = useRef(0); + + useEffect(() => { + lookupAttemptTokenRef.current += 1; + setLookupState({ tag: "idle" }); + }, [agentId, serverId]); + + useEffect(() => { + if (!agentId) { + return; + } + if (agentState.id) { + if (lookupState.tag !== "idle") { + setLookupState({ tag: "idle" }); + } + return; + } + if (!isConnected || !hasSession) { + return; + } + if (lookupState.tag === "loading" || lookupState.tag === "not_found") { + return; + } + + setLookupState({ tag: "loading" }); + const attemptToken = ++lookupAttemptTokenRef.current; + + client + .fetchAgent(agentId) + .then((result) => { + if (attemptToken !== lookupAttemptTokenRef.current) { + return; + } + if (!result) { + setLookupState({ + tag: "not_found", + message: `Agent not found: ${agentId}`, + }); + return; + } + + const normalized = normalizeAgentSnapshot(result.agent, serverId); + const hydrated = { + ...normalized, + projectPlacement: result.project, + }; + setAgents(serverId, (previous) => { + const next = new Map(previous); + next.set(hydrated.id, hydrated); + return next; + }); + setPendingPermissions(serverId, (previous) => { + const next = new Map(previous); + for (const [key, pending] of next.entries()) { + if (pending.agentId === hydrated.id) { + next.delete(key); + } + } + for (const request of hydrated.pendingPermissions) { + const key = derivePendingPermissionKey(hydrated.id, request); + next.set(key, { key, agentId: hydrated.id, request }); + } + return next; + }); + setLookupState({ tag: "idle" }); + }) + .catch((error) => { + if (attemptToken !== lookupAttemptTokenRef.current) { + return; + } + const message = toErrorMessage(error); + if (isNotFoundErrorMessage(message)) { + setLookupState({ tag: "not_found", message }); + return; + } + setLookupState({ tag: "error", message }); + }); + }, [ + agentId, + agentState.id, + client, + hasSession, + isConnected, + lookupState.tag, + serverId, + setAgents, + setPendingPermissions, + ]); + + if (lookupState.tag === "not_found") { + return ( + + + Agent not found + + + ); + } + + if (lookupState.tag === "error") { + return ( + + + Failed to load agent + {lookupState.message} + + + ); + } + + const agent: AgentScreenAgent | null = + agentState.serverId && agentState.id && agentState.status && agentState.cwd + ? { + serverId: agentState.serverId, + id: agentState.id, + status: agentState.status, + cwd: agentState.cwd, + lastError: agentState.lastError ?? null, + projectPlacement, + } + : null; + + if (!agent) { + return ( + + + + + + ); + } + + const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId })); + + return ( + + ); +} + +function ChatAgentContent({ + serverId, + agentId, + isPaneFocused, + client, + isConnected, + connectionStatus, + onOpenWorkspaceFile, +}: { + serverId: string; + agentId?: string; + isPaneFocused: boolean; + client: NonNullable>; + isConnected: boolean; + connectionStatus: HostRuntimeConnectionStatus; + onOpenWorkspaceFile?: (input: { filePath: string }) => void; }) { const { theme } = useUnistyles(); const panelToast = useToastHost(); @@ -238,13 +432,6 @@ function AgentPanelBody({ routeKey: string; reason: "initial-entry" | "resume"; } | null>(null); - const agentInputDraft = useAgentInputDraft( - buildDraftStoreKey({ - serverId, - agentId: agentId ?? "__pending__", - }), - ); - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { addImagesRef.current?.(files); }, []); @@ -261,12 +448,20 @@ function AgentPanelBody({ id: agent?.id ?? null, status: agent?.status ?? null, cwd: agent?.cwd ?? null, + lastError: agent?.lastError ?? null, archivedAt: agent?.archivedAt ?? null, requiresAttention: agent?.requiresAttention ?? false, attentionReason: agent?.attentionReason ?? null, }; }), ); + const agentInputDraft = useAgentInputDraft({ + draftKey: buildDraftStoreKey({ + serverId, + agentId: agentId ?? "__pending__", + }), + initialCwd: agentState.cwd ?? "", + }); const projectPlacement = useStoreWithEqualityFn( useSessionStore, (state) => @@ -492,10 +687,18 @@ function AgentPanelBody({ id: agentState.id, status: agentState.status, cwd: agentState.cwd, + lastError: agentState.lastError ?? null, projectPlacement, } : null, - [agentState.serverId, agentState.id, agentState.status, agentState.cwd, projectPlacement], + [ + agentState.serverId, + agentState.id, + agentState.status, + agentState.cwd, + agentState.lastError, + projectPlacement, + ], ); const placeholderAgent: AgentScreenAgent | null = useMemo(() => { @@ -759,14 +962,15 @@ function AgentPanelBody({ {agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? ( - state.sessions[serverId]?.workspaces.get(workspaceId) ?? null, + ); + const authority = useMemo(() => resolveWorkspaceExecutionAuthority({ workspace }), [workspace]); invariant(target.kind === "file", "FilePanel requires file target"); - return ; + if (!authority) { + return ( + + Workspace execution directory not found. + + ); + } + return ( + + ); } export const filePanelRegistration: PanelRegistration<"file"> = { diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index 760671b65..dfc14f7fe 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -2,6 +2,7 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; +import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; let panelsRegistered = false; @@ -12,6 +13,7 @@ export function ensurePanelsRegistered(): void { } registerPanel(draftPanelRegistration); registerPanel(agentPanelRegistration); + registerPanel(setupPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(filePanelRegistration); panelsRegistered = true; diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx new file mode 100644 index 000000000..3931ab2ec --- /dev/null +++ b/packages/app/src/panels/setup-panel.tsx @@ -0,0 +1,438 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; +import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native"; +import invariant from "tiny-invariant"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; + +function useSetupPanelDescriptor( + target: { kind: "setup"; workspaceId: string }, + context: { serverId: string; workspaceId: string }, +): PanelDescriptor { + const key = buildWorkspaceTabPersistenceKey({ + serverId: context.serverId, + workspaceId: target.workspaceId, + }); + const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); + + if (snapshot?.status === "completed") { + return { + label: "Setup", + subtitle: "Setup completed", + titleState: "ready", + icon: CheckCircle2, + statusBucket: null, + }; + } + + if (snapshot?.status === "failed") { + return { + label: "Setup", + subtitle: "Setup failed", + titleState: "ready", + icon: CircleAlert, + statusBucket: null, + }; + } + + return { + label: "Setup", + subtitle: "Workspace setup", + titleState: "ready", + icon: SquareTerminal, + statusBucket: snapshot?.status === "running" ? "running" : null, + }; +} + +type CommandStatus = "running" | "completed" | "failed"; + +function CommandStatusIcon({ status }: { status: CommandStatus }) { + const { theme } = useUnistyles(); + + if (status === "running") { + return ; + } + if (status === "completed") { + return ; + } + return ; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +/** + * Process carriage returns in log text so progress-bar output renders cleanly. + * Splits on \r, keeps only the last segment per CR-delimited group (unless followed by \n). + */ +function processCarriageReturns(text: string): string { + if (!text.includes("\r")) return text; + return text + .split("\n") + .map((line) => { + if (!line.includes("\r")) return line; + const segments = line.split("\r"); + return segments[segments.length - 1]; + }) + .join("\n"); +} + +function SetupPanel() { + const { theme } = useUnistyles(); + const { serverId, target } = usePaneContext(); + invariant(target.kind === "setup", "SetupPanel requires setup target"); + + const client = useHostRuntimeClient(serverId); + const key = buildWorkspaceTabPersistenceKey({ + serverId, + workspaceId: target.workspaceId, + }); + const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); + const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress); + + // On mount, if no snapshot in the store, request cached status from server + const requestedRef = useRef(false); + useEffect(() => { + if (snapshot || requestedRef.current || !client) return; + requestedRef.current = true; + client + .fetchWorkspaceSetupStatus(target.workspaceId) + .then((response) => { + if (response.snapshot) { + upsertProgress({ + serverId, + payload: { workspaceId: response.workspaceId, ...response.snapshot }, + }); + } + }) + .catch(() => { + // Server may not support this yet — ignore + }); + }, [client, snapshot, serverId, target.workspaceId, upsertProgress]); + + const commands = snapshot?.detail.commands ?? []; + const log = snapshot?.detail.log ?? ""; + const hasNoSetupCommands = + snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0; + const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0); + + const [expandedIndices, setExpandedIndices] = useState>(new Set()); + const [manuallyCollapsed, setManuallyCollapsed] = useState>(new Set()); + + const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => { + setExpandedIndices((prev) => { + const next = new Set(prev); + if (next.has(index) || isAutoExpanded) { + next.delete(index); + // If this was auto-expanded, record that the user manually collapsed it + if (isAutoExpanded) { + setManuallyCollapsed((mc) => new Set(mc).add(index)); + } + } else { + next.add(index); + // If the user re-expands, remove from manually collapsed + setManuallyCollapsed((mc) => { + const next = new Set(mc); + next.delete(index); + return next; + }); + } + return next; + }); + }, []); + + // Determine which command should auto-expand (running or last completed). + const autoExpandIndex = (() => { + const running = commands.find((c) => c.status === "running"); + if (running) return running.index; + if (commands.length > 0) return commands[commands.length - 1].index; + return null; + })(); + + const statusLabel = + snapshot?.status === "running" + ? "Running" + : snapshot?.status === "completed" + ? "Completed" + : snapshot?.status === "failed" + ? "Failed" + : "Waiting for setup output"; + + return ( + + {/* Hidden element for status — preserves testID for E2E */} + + {statusLabel} + + + {isWaiting ? ( + + + Setting up workspace... + + ) : hasNoSetupCommands ? ( + + + No setup commands ran for this workspace. + + + ) : ( + + {commands.map((command) => { + const isExpanded = expandedIndices.has(command.index); + const hasError = command.status === "failed" && snapshot?.error; + + // Per-command log: use command.log if available, fall back to detail.log for the auto-expand target + const commandLog = (() => { + if ("log" in command && typeof command.log === "string") { + return command.log; + } + // Fallback: show detail.log on the auto-expand target command + if (command.index === autoExpandIndex) return log; + return ""; + })(); + const hasLog = commandLog.trim().length > 0; + + // All non-running commands are expandable (completed/failed) + const isExpandable = command.status !== "running" || hasLog || !!hasError; + + // Auto-expand the active command unless the user manually collapsed it + const isAutoExpanded = + command.index === autoExpandIndex && !manuallyCollapsed.has(command.index); + const showDetail = isExpanded || isAutoExpanded; + + const processedLog = hasLog ? processCarriageReturns(commandLog) : ""; + + return ( + + toggleExpanded(command.index, isAutoExpanded)} + style={({ pressed }) => [ + styles.commandRow, + showDetail && styles.commandRowExpanded, + pressed && styles.commandRowPressed, + ]} + accessibilityRole="button" + accessibilityState={{ expanded: showDetail }} + > + + + + + {command.command} + + {command.durationMs != null ? ( + {formatDuration(command.durationMs)} + ) : null} + + + {showDetail ? ( + + {hasLog ? ( + + + {processedLog} + + + ) : ( + + No output + + )} + {hasError ? ( + + + {snapshot.error} + + + ) : null} + + ) : null} + + ); + })} + + {/* If there's log but no commands yet, or log without a target command, show standalone */} + {commands.length === 0 && log.trim().length > 0 ? ( + + + {log} + + + ) : null} + + {/* Show error at top level if no commands failed but there's a setup error */} + {snapshot?.error && !commands.some((c) => c.status === "failed") ? ( + + + {snapshot.error} + + + ) : null} + + )} + + ); +} + +export const setupPanelRegistration: PanelRegistration<"setup"> = { + kind: "setup", + component: SetupPanel, + useDescriptor: useSetupPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + minHeight: 0, + backgroundColor: theme.colors.surface0, + }, + contentContainer: { + padding: theme.spacing[4], + flexGrow: 1, + }, + hiddenStatus: { + position: "absolute", + width: 1, + height: 1, + overflow: "hidden", + opacity: 0, + }, + waitingContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + }, + waitingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + emptyContainer: { + flex: 1, + alignItems: "center", + justifyContent: "center", + }, + emptyText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + commandList: { + gap: theme.spacing[2], + }, + commandItem: { + borderRadius: theme.borderRadius.lg, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + overflow: "hidden", + }, + commandRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface1, + }, + commandRowExpanded: { + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + commandRowPressed: { + opacity: 0.8, + }, + commandStatusIcon: { + width: 18, + height: 18, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + commandText: { + flex: 1, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + commandDuration: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + flexShrink: 0, + }, + chevron: { + flexShrink: 0, + }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], + }, + commandDetail: { + backgroundColor: theme.colors.surface0, + }, + logScroll: { + maxHeight: 400, + }, + logScrollContent: { + padding: theme.spacing[3], + }, + logText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.sm, + lineHeight: 20, + color: theme.colors.foreground, + }, + emptyLogText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + fontStyle: "italic", + }, + errorCard: { + padding: theme.spacing[3], + backgroundColor: theme.colors.palette.red[100], + }, + errorText: { + fontSize: theme.fontSize.sm, + color: theme.colors.palette.red[800], + }, +})); diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 2f77fec4c..020d46479 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Terminal } from "lucide-react-native"; -import { View } from "react-native"; +import { Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import invariant from "tiny-invariant"; import type { ListTerminalsResponse } from "@server/shared/messages"; @@ -8,6 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane"; import { usePaneContext } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; +import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -24,14 +25,26 @@ function useTerminalPanelDescriptor( context: { serverId: string; workspaceId: string }, ): PanelDescriptor { const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); + const workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ + workspaces, + workspaceId: context.workspaceId, + }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; const terminalsQuery = useQuery({ - queryKey: ["terminals", context.serverId, context.workspaceId] as const, - enabled: Boolean(client && context.workspaceId), + queryKey: ["terminals", context.serverId, workspaceDirectory] as const, + enabled: Boolean(client && workspaceDirectory), queryFn: async (): Promise => { - if (!client) { - return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" }; + if (!client || !workspaceDirectory) { + throw new Error( + workspaceAuthority.ok + ? "Workspace execution directory not found" + : workspaceAuthority.message, + ); } - return client.listTerminals(context.workspaceId); + return client.listTerminals(workspaceDirectory); }, staleTime: 5_000, }); @@ -39,7 +52,7 @@ function useTerminalPanelDescriptor( terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null; return { - label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal", + label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal", subtitle: "Terminal", titleState: "ready", icon: Terminal, @@ -50,16 +63,33 @@ function useTerminalPanelDescriptor( function TerminalPanel() { const isFocused = useIsFocused(); const { serverId, workspaceId, target, isPaneFocused } = usePaneContext(); + const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); + const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId }); + const workspaceDirectory = workspaceAuthority.ok + ? workspaceAuthority.authority.workspaceDirectory + : null; invariant(target.kind === "terminal", "TerminalPanel requires terminal target"); if (!isFocused) { return ; } + if (!workspaceDirectory) { + return ( + + + {workspaceAuthority.ok + ? "Workspace execution directory not found." + : workspaceAuthority.message} + + + ); + } + return ( diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b815ce792..610d6f4c9 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -552,6 +552,39 @@ describe("HostRuntimeController", () => { unsubscribe(); }); + it("preserves transport disconnect reasons on the runtime snapshot", async () => { + const host = makeHost({ + connections: [ + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }, + ], + }); + const clients: FakeDaemonClient[] = []; + const controller = new HostRuntimeController({ + host, + deps: makeDeps( + { + "direct:lan:6767": 12, + }, + clients, + ), + }); + + await controller.start({ autoProbe: false }); + clients[0]?.setConnectionState({ + status: "disconnected", + reason: "transport closed", + }); + + expect(controller.getSnapshot()).toMatchObject({ + connectionStatus: "error", + lastError: "transport closed", + }); + }); + it("does not emit legacy typed reason-code transition logs", async () => { const infoSpy = vi.spyOn(console, "info").mockImplementation(() => undefined); try { diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index f31c1000e..da72b5ce8 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -11,15 +11,14 @@ import Animated from "react-native-reanimated"; import { Folder, GitBranch, PanelRight } from "lucide-react-native"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; -import { AgentInputArea } from "@/components/agent-input-area"; +import { Composer } from "@/components/composer"; import { AgentStreamView } from "@/components/agent-stream-view"; import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns"; import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { Combobox } from "@/components/ui/combobox"; import { FileDropZone } from "@/components/file-drop-zone"; import { useQuery } from "@tanstack/react-query"; -import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; -import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; +import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, @@ -27,6 +26,7 @@ import { import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import { useHosts } from "@/runtime/host-runtime"; import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { shortenPath } from "@/utils/shorten-path"; import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions"; import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; @@ -49,6 +49,8 @@ import type { AgentCapabilityFlags, AgentSessionConfig, } from "@server/server/agent/agent-sdk-types"; +import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest"; +import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; @@ -197,39 +199,44 @@ function DraftAgentScreenContent({ return values; }, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]); + const draftIdRef = useRef(generateDraftId()); + const draftAgentIdRef = useRef(generateDraftId()); + const draftInput = useAgentInputDraft({ + draftKey: ({ selectedServerId }) => + buildDraftStoreKey({ + serverId: selectedServerId ?? "", + agentId: draftAgentIdRef.current, + draftId: draftIdRef.current, + }), + initialCwd: resolvedWorkingDir ?? "", + composer: { + initialServerId: resolvedServerId ?? null, + initialValues, + isVisible, + onlineServerIds, + }, + }); + const composerState = draftInput.composerState; + if (!composerState) { + throw new Error("Draft agent composer state is required"); + } + const { selectedServerId, setSelectedServerIdFromUser, - selectedProvider, - setProviderFromUser, - selectedMode, - setModeFromUser, - selectedModel, - setModelFromUser, - selectedThinkingOptionId, - setThinkingOptionFromUser, + providerDefinitions, workingDir, setWorkingDirFromUser, - providerDefinitions, modeOptions, - availableModels, - allProviderModels, - allProviderEntries, - isAllModelsLoading, - availableThinkingOptions, isModelLoading, modelError, refreshProviderModels, - invalidateProviderModels, - setProviderAndModelFromUser, persistFormPreferences, - } = useAgentFormState({ - initialServerId: resolvedServerId ?? null, - initialValues, - isVisible, - isCreateFlow: true, - onlineServerIds, - }); + effectiveModelId, + effectiveThinkingOptionId, + commandDraftConfig, + statusControls, + } = composerState; const isMobile = useIsCompactFormFactor(); const mobileView = usePanelStore((state) => state.mobileView); const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); @@ -241,15 +248,6 @@ function DraftAgentScreenContent({ (state) => state.activateExplorerTabForCheckout, ); const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; - const draftIdRef = useRef(generateDraftId()); - const draftAgentIdRef = useRef(generateDraftId()); - const draftInput = useAgentInputDraft( - buildDraftStoreKey({ - serverId: selectedServerId ?? "", - agentId: draftAgentIdRef.current, - draftId: draftIdRef.current, - }), - ); const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">( initialWorktreeMode, @@ -629,6 +627,18 @@ function DraftAgentScreenContent({ isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true, }; }, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]); + const draftExplorerWorkspaceId = useSessionStore( + useCallback( + (state) => + resolveWorkspaceIdByExecutionDirectory({ + workspaces: selectedServerId + ? state.sessions[selectedServerId]?.workspaces?.values() + : null, + workspaceDirectory: explorerCwd, + }), + [explorerCwd, selectedServerId], + ), + ); const canOpenExplorer = draftExplorerCheckout !== null; const openExplorerForDraftCheckout = useCallback(() => { if (!draftExplorerCheckout) { @@ -740,61 +750,6 @@ function DraftAgentScreenContent({ }, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]); const createAgentClient = sessionClient; - const effectiveDraftModelId = useMemo(() => { - if (selectedModel.trim()) { - return selectedModel.trim(); - } - return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? ""; - }, [availableModels, selectedModel]); - const effectiveDraftThinkingOptionId = useMemo(() => { - if (selectedThinkingOptionId.trim()) { - return selectedThinkingOptionId.trim(); - } - const selectedModelDefinition = - availableModels.find((model) => model.id === effectiveDraftModelId) ?? null; - return selectedModelDefinition?.defaultThinkingOptionId ?? ""; - }, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]); - const { - features: draftFeatures, - featureValues: draftFeatureValues, - setFeatureValue: setDraftFeatureValue, - } = useDraftAgentFeatures({ - serverId: selectedServerId, - provider: selectedProvider, - cwd: workingDir, - modeId: selectedMode, - modelId: effectiveDraftModelId, - thinkingOptionId: effectiveDraftThinkingOptionId, - }); - const draftCommandConfig = useMemo(() => { - const cwd = ( - isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir - ).trim(); - if (!cwd) { - return undefined; - } - - return { - provider: selectedProvider, - cwd, - ...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}), - ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), - ...(effectiveDraftThinkingOptionId - ? { thinkingOptionId: effectiveDraftThinkingOptionId } - : {}), - ...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}), - }; - }, [ - draftFeatureValues, - effectiveDraftModelId, - effectiveDraftThinkingOptionId, - isAttachWorktree, - modeOptions.length, - selectedMode, - selectedProvider, - selectedWorktreePath, - workingDir, - ]); const { formErrorMessage, @@ -802,7 +757,7 @@ function DraftAgentScreenContent({ optimisticStreamItems, draftAgent, handleCreateFromInput, - } = useDraftAgentCreateFlow({ + } = useDraftAgentCreateFlow({ draftId: draftIdRef.current, getPendingServerId: () => selectedServerId, validateBeforeSubmit: ({ text }) => { @@ -828,7 +783,7 @@ function DraftAgentScreenContent({ if (isModelLoading) { return "Model defaults are still loading"; } - if (!effectiveDraftModelId) { + if (!effectiveModelId) { return "No model is available for the selected provider"; } if (isAttachWorktree && !selectedWorktreePath) { @@ -861,10 +816,13 @@ function DraftAgentScreenContent({ const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || "."; - const provider = selectedProvider; - const model = effectiveDraftModelId || null; - const thinkingOptionId = effectiveDraftThinkingOptionId || null; - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null; + const provider = composerState.selectedProvider; + const model = effectiveModelId || null; + const thinkingOptionId = effectiveThinkingOptionId || null; + const modeId = + composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? composerState.selectedMode + : null; return { serverId, @@ -889,26 +847,25 @@ function DraftAgentScreenContent({ title: "New agent", cwd, model, - features: draftFeatures, thinkingOptionId, labels: {}, }; }, - createRequest: async ({ attempt, text, images }) => { + createRequest: async ({ attempt, text, images, attachments }) => { const trimmedPath = workingDir.trim(); const resolvedWorkingDir = isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath; - const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined; + const modeId = + composerState.modeOptions.length > 0 && composerState.selectedMode !== "" + ? composerState.selectedMode + : undefined; const config: AgentSessionConfig = { - provider: selectedProvider, + provider: composerState.selectedProvider, cwd: resolvedWorkingDir, ...(modeId ? { modeId } : {}), - ...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}), - ...(effectiveDraftThinkingOptionId - ? { thinkingOptionId: effectiveDraftThinkingOptionId } - : {}), - ...(draftFeatureValues ? { featureValues: draftFeatureValues } : {}), + ...(effectiveModelId ? { model: effectiveModelId } : {}), + ...(effectiveThinkingOptionId ? { thinkingOptionId: effectiveThinkingOptionId } : {}), }; const effectiveBaseBranch = baseBranch.trim(); @@ -940,6 +897,7 @@ function DraftAgentScreenContent({ initialPrompt: text, clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), git: gitOptions, }); @@ -955,20 +913,27 @@ function DraftAgentScreenContent({ const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : ""; const configuredWorkingDir = config.cwd.trim(); - const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir; + const workspaceId = resolveWorkspaceIdByExecutionDirectory({ + workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(), + workspaceDirectory: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir, + }); return { agentId: result.id, result: { id: result.id, - cwd: workspaceId, + workspaceId, }, }; }, onCreateSuccess: ({ result }) => { + if (!result.workspaceId) { + router.replace(buildHostAgentDetailRoute(selectedServerId as string, result.id) as any); + return; + } const route = prepareWorkspaceTab({ serverId: selectedServerId as string, - workspaceId: result.cwd, + workspaceId: result.workspaceId, target: { kind: "agent", agentId: result.id }, }); router.replace(route as any); @@ -1229,7 +1194,7 @@ function DraftAgentScreenContent({ )} - @@ -1273,7 +1221,7 @@ function DraftAgentScreenContent({ {!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? ( @@ -1296,7 +1244,7 @@ function DraftAgentScreenContent({ {isMobile && explorerServerId && draftExplorerCheckout ? ( diff --git a/packages/app/src/screens/new-workspace-picker-item.test.ts b/packages/app/src/screens/new-workspace-picker-item.test.ts new file mode 100644 index 000000000..6343ad061 --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-item.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import { pickerItemToCheckoutRequest, type PickerItem } from "./new-workspace-picker-item"; + +const prItem: GitHubSearchItem = { + kind: "pr", + number: 42, + title: "Add picker", + url: "https://example.com/pull/42", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature/picker", +}; + +describe("pickerItemToCheckoutRequest", () => { + it("returns undefined for no selection (null)", () => { + expect(pickerItemToCheckoutRequest(null)).toBeUndefined(); + }); + + it("maps a branch row to branch-off with the branch name", () => { + const item: PickerItem = { kind: "branch", name: "dev" }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "branch-off", + refName: "dev", + }); + }); + + it("maps a github-pr row to checkout using the head ref and pr number", () => { + const item: PickerItem = { + kind: "github-pr", + item: prItem, + }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "checkout", + refName: "feature/picker", + githubPrNumber: 42, + }); + }); + + it("handles a github-pr with a null baseRef", () => { + const item: PickerItem = { + kind: "github-pr", + item: { + ...prItem, + number: 7, + title: "Orphan branch", + baseRefName: null, + headRefName: "orphan", + }, + }; + expect(pickerItemToCheckoutRequest(item)).toEqual({ + action: "checkout", + refName: "orphan", + githubPrNumber: 7, + }); + }); +}); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts new file mode 100644 index 000000000..77808b8a4 --- /dev/null +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -0,0 +1,30 @@ +import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client"; +import type { GitHubSearchItem } from "@server/shared/messages"; + +export type PickerItem = + | { kind: "branch"; name: string } + | { + kind: "github-pr"; + item: GitHubSearchItem; + }; + +export type PickerCheckoutRequest = Pick< + CreatePaseoWorktreeInput, + "action" | "refName" | "githubPrNumber" +>; + +export function pickerItemToCheckoutRequest( + item: PickerItem | null, +): PickerCheckoutRequest | undefined { + if (!item) return undefined; + switch (item.kind) { + case "branch": + return { action: "branch-off", refName: item.name }; + case "github-pr": + return { + action: "checkout", + refName: item.item.headRefName ?? "", + githubPrNumber: item.item.number, + }; + } +} diff --git a/packages/app/src/screens/new-workspace-screen.test.tsx b/packages/app/src/screens/new-workspace-screen.test.tsx new file mode 100644 index 000000000..ffe4968d8 --- /dev/null +++ b/packages/app/src/screens/new-workspace-screen.test.tsx @@ -0,0 +1,788 @@ +import React 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 { ComposerAttachment } from "@/attachments/types"; +import type { CreatePaseoWorktreeInput } from "@server/client/daemon-client"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import { NewWorkspaceScreen } from "./new-workspace-screen"; + +const { + theme, + mockClient, + mergeWorkspacesMock, + setAgentsMock, + navigateMock, + createdAgent, + createdWorkspace, + prItem, + prItemB, + issueItem, + initialAttachments, + initialDraftState, +} = 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: { md: 6, lg: 8, "2xl": 16 }, + fontSize: { xs: 11, sm: 13, base: 15, lg: 18 }, + fontWeight: { medium: "500" }, + shadow: { md: {} }, + colors: { + surface0: "#000", + surface1: "#111", + surface2: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + popoverForeground: "#fff", + border: "#555", + destructive: "#ff453a", + palette: { + zinc: { 600: "#52525b" }, + }, + }, + }; + + const prItem: GitHubSearchItem = { + kind: "pr", + number: 202, + title: "Refactor picker", + url: "https://example.com/pull/202", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature/picker", + }; + + const prItemB: GitHubSearchItem = { + kind: "pr", + number: 303, + title: "Polish composer chip", + url: "https://example.com/pull/303", + state: "open", + body: null, + labels: [], + baseRefName: "main", + headRefName: "feature/composer-chip", + }; + + const issueItem: GitHubSearchItem = { + kind: "issue", + number: 44, + title: "Keep manual attachment", + url: "https://example.com/issues/44", + state: "open", + body: null, + labels: [], + }; + + const initialAttachments: ComposerAttachment[] = []; + const initialDraftState = { text: "" }; + + const createdWorkspace = { + id: "workspace-1", + workspaceDirectory: "/repo/.paseo/worktrees/workspace-1", + }; + + const createdAgent = { + id: "agent-1", + cwd: createdWorkspace.workspaceDirectory, + }; + + const mockClient = { + isConnected: true, + getCheckoutStatus: vi.fn(async () => ({ currentBranch: "main" })), + getBranchSuggestions: vi.fn(async () => ({ branches: ["main", "dev", "feat/x"] })), + searchGitHub: vi.fn(async () => ({ + items: [prItem, prItemB], + githubFeaturesEnabled: true, + error: null, + })), + createPaseoWorktree: vi.fn(async (_input: CreatePaseoWorktreeInput) => ({ + workspace: createdWorkspace, + error: null, + })), + createAgent: vi.fn(async () => createdAgent), + }; + + return { + theme, + mockClient, + mergeWorkspacesMock: vi.fn(), + setAgentsMock: vi.fn(), + navigateMock: vi.fn(), + createdAgent, + createdWorkspace, + prItem, + prItemB, + issueItem, + initialAttachments, + initialDraftState, + }; +}); + +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", () => ({ + HEADER_INNER_HEIGHT: 48, + HEADER_INNER_HEIGHT_MOBILE: 56, + HEADER_TOP_PADDING_MOBILE: 8, + MAX_CONTENT_WIDTH: 900, + useIsCompactFormFactor: () => false, +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { ...props, "data-icon": name }); + return { + ChevronDown: createIcon("ChevronDown"), + GitBranch: createIcon("GitBranch"), + GitPullRequest: createIcon("GitPullRequest"), + }; +}); + +vi.mock("@/runtime/host-runtime", () => ({ + useHostRuntimeClient: () => mockClient, + useHostRuntimeIsConnected: () => true, +})); + +vi.mock("@/stores/session-store", () => ({ + useSessionStore: (selector: (state: unknown) => unknown) => + selector({ + mergeWorkspaces: mergeWorkspacesMock, + setAgents: setAgentsMock, + }), + normalizeWorkspaceDescriptor: (workspace: unknown) => workspace, +})); + +vi.mock("@/utils/agent-snapshots", () => ({ + normalizeAgentSnapshot: (agent: unknown) => agent, +})); + +vi.mock("@/utils/encode-images", () => ({ + encodeImages: vi.fn(async () => []), +})); + +vi.mock("@/utils/error-messages", () => ({ + toErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), +})); + +vi.mock("@/utils/workspace-execution", () => ({ + requireWorkspaceExecutionAuthority: (input: { workspace: { workspaceDirectory: string } }) => ({ + workspaceDirectory: input.workspace.workspaceDirectory, + }), +})); + +vi.mock("@/utils/workspace-navigation", () => ({ + navigateToPreparedWorkspaceTab: navigateMock, +})); + +vi.mock("@/contexts/toast-context", () => ({ + useToast: () => ({ error: vi.fn() }), +})); + +vi.mock("@/hooks/use-agent-input-draft", () => ({ + useAgentInputDraft: () => { + const [attachments, setAttachments] = React.useState(initialAttachments); + const [text, setText] = React.useState(initialDraftState.text); + return { + text, + setText, + attachments, + setAttachments, + cwd: "/repo", + composerState: { + selectedProvider: "claude-code", + selectedMode: "", + modeOptions: [], + effectiveModelId: null, + effectiveThinkingOptionId: null, + commandDraftConfig: undefined, + statusControls: {}, + }, + }; + }, +})); + +vi.mock("@/components/composer", () => ({ + Composer: ({ + onSubmitMessage, + submitBehavior, + submitIcon, + isSubmitLoading, + value, + onChangeText, + attachments, + onChangeAttachments, + }: { + onSubmitMessage: (payload: { + text: string; + attachments: ComposerAttachment[]; + cwd: string; + }) => void; + submitBehavior?: "clear" | "preserve-and-lock"; + submitIcon?: "arrow" | "return"; + isSubmitLoading?: boolean; + value: string; + onChangeText: (text: string) => void; + attachments: ComposerAttachment[]; + onChangeAttachments: (attachments: ComposerAttachment[]) => void; + }) => ( +
+