mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9457bb6f40 | ||
|
|
7e6ad30f9b | ||
|
|
003b6139f0 | ||
|
|
433e67b18b | ||
|
|
d311797653 | ||
|
|
0b3d82a469 | ||
|
|
509a3e17bd | ||
|
|
19bd6e5909 | ||
|
|
16bfff8e0f | ||
|
|
fdf3b4b47f | ||
|
|
96ea6698d7 | ||
|
|
85cd969321 | ||
|
|
c172076bff | ||
|
|
613cbbe9ef | ||
|
|
da8c1b5c94 | ||
|
|
18aaec2772 | ||
|
|
158525624f | ||
|
|
47171b419b | ||
|
|
f809651876 |
+61
-99
@@ -1,142 +1,104 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thank you for taking the time to contribute to Paseo.
|
||||
This guide is here to save us both time and help you find a useful way to contribute.
|
||||
|
||||
Paseo grows through bug reports, testing, workflow discussions, plugins, documentation, and people helping each other. The sections below explain where each contribution belongs and what to expect.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Paseo is an opinionated product, built on freedom and flexibility: any agent provider, any device, running on your own machine. It is meant to be composable, so you can build the workflow you want. Read more about the product vision [here](docs/product.md).
|
||||
**Paseo has a lean, opinionated core built to be extended.**
|
||||
|
||||
Given Paseo's scope, contributing to it takes a lot of context that is very hard to transfer. That's why product, design, architecture, and workflow decisions are currently all made by the maintainer.
|
||||
The goal is a low floor and a high ceiling: a polished default experience that is easy to understand, with the flexibility to choose your providers, run on your own infrastructure, and build your own workflows. Paseo should remain self-hosted and respectful of your privacy and control.
|
||||
|
||||
I pick what to build based on whether it fits the product, how many workflows it improves, whether it keeps things composable, whether we can hold the quality bar, and whether I want to build it.
|
||||
The core continues to evolve through improvements to the shared experience. Specialized workflows and integrations belong in the extension ecosystem. Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
## Report bugs in GitHub issues or Discord
|
||||
Read the [product philosophy](docs/product.md) for the reasoning behind these choices and how Paseo develops.
|
||||
|
||||
Open an [issue](https://github.com/getpaseo/paseo/issues) or post in [Discord](https://discord.gg/jz8T2uahpH).
|
||||
## Build a plugin
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Feature requests opened as issues will get closed automatically
|
||||
Most specialized workflows and integrations are better served by plugins.
|
||||
|
||||
If you used an agent to investigate, paste the raw evidence and repro steps it collected, not its diagnosis.
|
||||
Plugins let you build what you need, share it, and maintain it independently. Start with the [plugin documentation](https://paseo.sh/docs/plugins). For applications and integrations built around Paseo, see the [SDK documentation](https://paseo.sh/docs/sdk).
|
||||
|
||||
You may submit a PR to fix a bug, please read the PR guidelines below.
|
||||
If an extension point is missing, describe the workflow in [Discussions](https://github.com/getpaseo/paseo/discussions). A reusable capability that enables several plugins may be a better addition than implementing one particular workflow in core.
|
||||
|
||||
## Product discussions go in GitHub Discussions or Discord
|
||||
## Report bugs
|
||||
|
||||
There's no feature request backlog. Open a product discussion in [Discussions](https://github.com/getpaseo/paseo/discussions) or `#product` in [Discord](https://discord.gg/jz8T2uahpH), and frame it as a workflow:
|
||||
Open a [GitHub issue](https://github.com/getpaseo/paseo/issues) and fill out the bug report template.
|
||||
|
||||
Include:
|
||||
|
||||
- What you did, what you expected, and what happened.
|
||||
- Reproduction steps, Paseo version, and platform.
|
||||
- Relevant logs, screenshots, or a recording.
|
||||
|
||||
If an agent investigated, include the evidence and reproduction steps it collected. Its explanation of the cause still needs verification.
|
||||
|
||||
Focused fixes for reproducible bugs are welcome. If you submit one, follow the pull request and QA guidance below.
|
||||
|
||||
## Discuss workflows before proposing features
|
||||
|
||||
Product discussions, including feature requests, belong in [GitHub Discussions](https://github.com/getpaseo/paseo/discussions). Feature requests opened as issues will be closed.
|
||||
|
||||
Explain:
|
||||
|
||||
- What are you trying to do?
|
||||
- How do you do it today?
|
||||
- Where does Paseo get in the way?
|
||||
- What would the flow look like if it worked for you?
|
||||
- What would a better workflow look like?
|
||||
|
||||
"Can you build X" is not useful because it doesn't tell me about your problem.
|
||||
This helps other people describe their needs and gives me useful context when deciding what to build.
|
||||
|
||||
A discussion also lets other people add their own version of the same problem, and that's what I read when I decide what to build.
|
||||
There is no feature request backlog or commitment to implement a discussion. I may read and learn from it without replying.
|
||||
|
||||
Due to the volume and my limited bandwidth, I may not participate in all the discussions, if something gains interest it will make it's way to me.
|
||||
## If you still want to submit a pull request
|
||||
|
||||
## Pull requests
|
||||
After considering plugins and discussing the workflow, you may still want to propose a change to core. Focused fixes for reproducible bugs are also welcome.
|
||||
|
||||
For plugin changes, start with the [SDK import boundaries](docs/plugins.md#sdk-import-boundaries).
|
||||
**Pull requests are closed by default.** I may reopen the ones I want to take forward. Submit one only if you are comfortable with it being closed without a detailed review or explanation.
|
||||
|
||||
Anyone can open a pull request, but there are no guarantees of it getting merged, only submit one if you're okay with it being closed.
|
||||
Paseo receives more pull requests than I can responsibly review. Reviewing every submission and explaining every decision would consume the time available for developing the product.
|
||||
|
||||
Open it as a draft if the work isn't ready for review, if you want to run the checks against it, or if you want feedback on the direction before you finish it. Mark it ready when you want it looked at.
|
||||
I can prompt an agent to write code just as you can. The bottlenecks are choosing what to build, shaping it, verifying its behavior, and taking responsibility for its maintenance. A finished implementation still leaves that work to do.
|
||||
|
||||
Here is the criteria I use to decide:
|
||||
If you choose to submit a PR, these are the basics to cover.
|
||||
|
||||
✅ Likely to be accepted
|
||||
### What I look for in a core feature
|
||||
|
||||
- Fits the product vision
|
||||
- One focused change
|
||||
- Links the bug it fixes, or the discussion behind it if there is one
|
||||
- Addresses bot reviews
|
||||
- Explains the problem you're solving
|
||||
- QA evidence
|
||||
- Automated tests
|
||||
- Screenshots or video for UI changes, on every affected platform
|
||||
- Says which platforms you tested and which you didn't
|
||||
- Maintainer edits enabled
|
||||
- **Demonstrated demand.** Link a discussion with concrete examples of other users needing the workflow. A feature for a small, specialized use case belongs in a plugin.
|
||||
- **Value across workflows.** The feature should improve how existing capabilities work together and benefit a broad set of users. A reusable improvement is more valuable than an isolated control for one task.
|
||||
- **A coherent design across platforms and providers.** Shared capabilities should work across platforms and providers wherever applicable. Do not reshape a shared abstraction around one provider while leaving the others unsupported or inconsistent.
|
||||
- **A finished experience.** Follow the [design guidelines](docs/design.md). The feature must look and feel right in Paseo, including loading states, layout stability, and interaction performance. PRs with janky or rushed interfaces will be closed. Design judgment remains with the maintainer.
|
||||
|
||||
⛔️ Will be rejected
|
||||
### Keep the scope small
|
||||
|
||||
- Bundles unrelated changes
|
||||
- Fails checks
|
||||
- Ignores bot reviews
|
||||
- Takes a feature or the design in a direction I don't want
|
||||
- No QA evidence
|
||||
- No tests
|
||||
- Clearly fully AI-generated PR
|
||||
Submit one focused change. PRs adding more than roughly **3,000 lines of production code** are unlikely to be accepted. Prefer the smallest complete improvement that can be tested and understood on its own.
|
||||
|
||||
### What to expect
|
||||
### QA evidence
|
||||
|
||||
- PRs that were explicitly approved in a discussion are preferred.
|
||||
- Unsolicited PR can be closed without a detailed review.
|
||||
- Your PR can be narrowed, refactored, or redesigned.
|
||||
- Your PR might be accepted but not merged immediately.
|
||||
- You will be attributed for your work in the changelog, even if I redesign your PR
|
||||
Run the changed application or service and test the affected workflow yourself. Include detailed QA evidence so I can see what you exercised and what happened. PRs without this verification and evidence will be closed, whether they fix a bug or add a feature.
|
||||
|
||||
## QA evidence
|
||||
Explain the problem and link the relevant bug report or discussion. Include:
|
||||
|
||||
QA is the main bottleneck of Paseo's product development, and it's not just whether the feature works, it's also whether it meets the quality bar.
|
||||
- Automated tests that exercise the changed behavior. A bug fix needs a regression test that fails on the broken version.
|
||||
- The commands you ran and their output.
|
||||
- A recording for interactive UI changes, or before-and-after screenshots for static changes.
|
||||
- The platforms you tested and any affected platforms you could not test.
|
||||
|
||||
Pull requests without evidence will be closed.
|
||||
The [QA guide](docs/qa.md) explains the expected evidence and available tooling.
|
||||
|
||||
What's accepted:
|
||||
Using an agent is welcome. Sending it to implement a change and submitting its output without trying the result yourself does not meet this bar.
|
||||
|
||||
- The shell commands you ran, pasted with their output
|
||||
- The tests you added and their results
|
||||
- Before and after screenshots
|
||||
- A video of the whole interaction
|
||||
- Logs, requests, responses
|
||||
**Satisfying all of the above does not mean your pull request will be merged.**
|
||||
|
||||
Bugs and features both need automated tests, and the tests have to exercise the real thing. UI changes need a video, or a screenshot if it's static.
|
||||
### What happens to your contribution
|
||||
|
||||
The [QA guide](docs/qa.md) covers what to check for each of these and the tooling to do it.
|
||||
If I take a contribution forward, I may narrow it, reshape it, or use it as a reference for my own implementation. **You will be credited for your contribution, including when I reimplement it.**
|
||||
|
||||
## I want to become a maintainer
|
||||
A closed PR does not necessarily mean the underlying problem was dismissed. It means I am not taking that submission forward. Detailed reviews, individual explanations, and follow-up discussions cannot be provided at this volume.
|
||||
|
||||
There's no formal process to become a maintainer, just consistently get involved with the project by answering questions, testing, reproducing bugs, discussing workflows with the community and offer to implement features when we reach an agreement.
|
||||
## Help the community
|
||||
|
||||
## FAQ
|
||||
Testing betas, reproducing bugs, improving documentation, sharing plugins, and answering questions all help Paseo develop.
|
||||
|
||||
### I'm blocked, where do I go?
|
||||
|
||||
Join [Discord](https://discord.gg/jz8T2uahpH), ask your questions there.
|
||||
|
||||
### Do you prefer Github or Discord?
|
||||
|
||||
I am more active on Discord for general questions.
|
||||
|
||||
I prefer Github for bugs because the template helps collect information more efficiently.
|
||||
|
||||
### Why was my feature request closed?
|
||||
|
||||
Issues are only for bugs. Open it as a product discussion instead, framed as a workflow.
|
||||
|
||||
### Why didn't my PR get a detailed review?
|
||||
|
||||
Writing a review is very time consuming and Paseo receives a lot of PRs, I cannot give detailed explanations at volume.
|
||||
|
||||
### How long until someone looks at my pull request?
|
||||
|
||||
It depends on what else is going on. QA takes longer than reading the diff. You are welcome to join Discord to ask.
|
||||
|
||||
### Why do you want tests and a video for a small change?
|
||||
|
||||
Because it's faster and safer than me checking out and running every PR myself to test it manually.
|
||||
|
||||
QA evidence help me take your PR seriously for a deeper review and maintainer QA.
|
||||
|
||||
### I only have one platform, can I still contribute?
|
||||
|
||||
Yes. Test what you have, and say what you didn't test.
|
||||
|
||||
### Can I use an agent to write my contribution?
|
||||
|
||||
Yes, as long as you understand what you're submitting and you tested it yourself.
|
||||
|
||||
### Is there a roadmap I can read?
|
||||
|
||||
[docs/product.md](docs/product.md) covers the direction. There's no public list of what I'm building next.
|
||||
There is no formal process for becoming a maintainer. Consistent involvement and good judgment build the shared context needed to take on more responsibility.
|
||||
|
||||
@@ -148,6 +148,10 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**.
|
||||
|
||||
Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track.
|
||||
|
||||
A finished workspace can be marked unread after it has been reviewed. The daemon restores
|
||||
`finished` attention on its newest eligible workspace-root agent without sending a new completion
|
||||
notification. Opening the workspace clears that attention through the normal focus flow.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`.
|
||||
|
||||
@@ -96,7 +96,8 @@ code imports from `@getpaseo/client`.
|
||||
|
||||
`PaseoApi` is the capability-only boundary over workspaces, agents, terminals, providers, and config.
|
||||
`PaseoClient` adds connection lifecycle. App plugin surfaces borrow an API over their selected
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport.
|
||||
host's client; plugin subprocesses use the same facade over a host-owned IPC transport. Protocol capability ownership and subscription lifetimes follow
|
||||
[the client contract](protocol-compatibility.md#client-capability-ownership).
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Paseo Hub is an explicit opt-in connection from one Paseo daemon to one Hub. Run
|
||||
not register it with a Hub. The relationship begins only when a user runs
|
||||
`paseo hub connect [url]` from the daemon machine with an explicit API key or matching stored CLI login.
|
||||
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold. `paseo hub export [directory]` writes the active organization's current triggers as one self-contained YAML file per trigger, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
The human CLI login and daemon relationship are separate identities. `paseo hub login [url]` stores a durable organization-scoped CLI credential keyed by normalized Hub origin under `PASEO_HOME`. Interactive login optionally connects the local daemon, then points to the Hub UI for trigger configuration; it does not scaffold or deploy configuration. `paseo hub init` remains the explicit triggers-as-code scaffold: it writes one self-contained organization trigger under `.paseo/triggers/`, validates it through the trigger API, and optionally installs it. `paseo hub deploy` validates and installs every trigger in that directory; passing `--project` keeps deploying the legacy project bundle instead. `paseo hub export [directory]` writes the active organization's current triggers in the same layout, using the active login unless another Hub or API key is selected. Origin resolution uses explicit command input, `PASEO_HUB_URL`, active login, then `https://hub.paseo.sh`. Connect uses exact-origin authority to request a one-time enrollment token, then passes only that token to the daemon. The daemon generates and persists its own relationship credential.
|
||||
|
||||
## Connection and authority
|
||||
|
||||
|
||||
+9
-41
@@ -1,7 +1,7 @@
|
||||
# Plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
client slash commands, timeline items, header buttons, composer pills, app themes, composer attachment sources, and settings screens.
|
||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
||||
|
||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||
@@ -344,48 +344,16 @@ external `href` or `xlink:href` references are rejected. Fragment references suc
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Contribute composer pills
|
||||
## Contribute buttons
|
||||
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
||||
surface.
|
||||
Header buttons and composer pills share the client-only descriptor and registration lifecycle in
|
||||
`packages/app/src/plugins/buttons/`. The [public button reference](../public-docs/plugins/v0.8/reference.md#header-buttons)
|
||||
owns the author API and placement rules. Keep presentation policy in this module so another
|
||||
placement can reuse behavior without copying registration or action state.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
const pills = new Map<string, () => void>();
|
||||
const unsubscribe = client.paseo.agents.subscribe((update) => {
|
||||
if (update.kind !== "upsert" || !update.agent.workspaceId) return;
|
||||
const { id: agentId, workspaceId } = update.agent;
|
||||
pills.get(agentId)?.();
|
||||
pills.set(
|
||||
agentId,
|
||||
client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open review",
|
||||
workspaceId,
|
||||
agentId,
|
||||
Component: ReviewPill,
|
||||
async onPress() {
|
||||
await client.rpc(refreshReview, { agentId });
|
||||
client.openPanel("review", { workspaceId, agentId });
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
for (const remove of pills.values()) remove();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
||||
`addComposerPill` returns an idempotent removal function. A pill appears only in the
|
||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||
the host, or unloading the app tears down the contribution.
|
||||
Native sheets teleport their children. Button surfaces rebuild the installation's SDK, state,
|
||||
query, and toast providers inside the surface content, including overflow pages from different
|
||||
plugins. Providers only around the trigger do not reach those bodies.
|
||||
|
||||
## Contribute timeline items
|
||||
|
||||
|
||||
+74
-53
@@ -1,81 +1,102 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
Paseo is an environment for running, monitoring, and interacting with coding agents across desktop, mobile, web, and the command line.
|
||||
|
||||
## What is Paseo
|
||||
**Paseo has a lean, opinionated core built to be extended.** It should be easy to start using and leave room for people to build far beyond the default experience.
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
## Agents are the focus
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
The central workflow is giving an agent a task, understanding what it is doing, providing direction, and reviewing the result. Files, terminals, diffs, and other supporting tools help you do that.
|
||||
|
||||
## Core philosophy
|
||||
Paseo should make this workflow coherent across providers and devices. You should be able to use the agent that fits a task and keep working from another device without rebuilding your environment around it.
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
This is the basis for product decisions. A familiar feature from another development tool earns its place by improving that experience. Familiarity alone does not make it a requirement for Paseo.
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor lock-in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — Apache-2.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
## A low floor and a high ceiling
|
||||
|
||||
## How it works
|
||||
### Easy to start
|
||||
|
||||
### Projects and workspaces
|
||||
The default experience should be lean, polished, and understandable to someone who has never run a server or configured a VPN.
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
With a supported coding agent installed and authenticated, you can open the desktop app and start working. The app manages its local daemon. To connect your phone through the optional Paseo relay, enable pairing and scan a QR code. You do not need to configure a VPN or understand the networking behind it.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
You should not need to know that plugins exist to get a useful, complete experience. Extra capability should become discoverable when it helps with something you want to do.
|
||||
|
||||
### Inside a workspace
|
||||
Keeping this starting point approachable is an ongoing responsibility. A technically correct feature can still make the product harder to understand through extra choices, controls, or setup.
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
### Room to build your own setup
|
||||
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
The same product should also work for someone running agents on a home server, using their own network, or building an automated workflow.
|
||||
|
||||
### The daemon
|
||||
The daemon can run independently of the desktop app. Clients connect to it locally or remotely, and multiple clients can use the same daemon. This separation lets you choose where the work runs and how you interact with it.
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
For example, someone can use the relay to connect their phone with minimal setup. Someone else can run a daemon on their own infrastructure and connect through SSH or a VPN. Both use the same underlying system.
|
||||
|
||||
This architecture means:
|
||||
Paseo should provide useful defaults while keeping these choices available. You should not have to adopt every part of the system to benefit from the parts you want.
|
||||
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when a client disconnects — the daemon owns them, not the client
|
||||
- Quitting the desktop app stops the daemon it started, so "restart the app" is a real fix; a daemon you run yourself is unaffected
|
||||
See the [connectivity guide](https://paseo.sh/docs/connectivity) for supported connection methods and [architecture](architecture.md) for the system design.
|
||||
|
||||
## Target user
|
||||
## Freedom, ownership, and privacy
|
||||
|
||||
Anyone who builds software:
|
||||
Paseo's flexibility depends on keeping control with the user:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
- **Self-hosted:** Run agents on your own machines, with your own environment, files, and credentials.
|
||||
- **Provider choice:** Use supported agent harnesses and extend provider support through plugins. Your workflow should be able to evolve as your choice of provider changes.
|
||||
- **Cross-device:** Use desktop, mobile, web, and CLI clients according to the situation.
|
||||
- **Privacy:** No Paseo telemetry, tracking, or forced account. The relay is optional and end-to-end encrypted.
|
||||
- **Open source:** Paseo is Apache-2.0 licensed. You can inspect it, modify it, and build on it.
|
||||
|
||||
## What compounds over time
|
||||
Convenience and ownership should work together. Offering a straightforward way to connect should preserve the option to operate your own setup.
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
## A lean core that keeps improving
|
||||
|
||||
## Strategic bets
|
||||
The core provides the shared experience: starting work, interacting with agents, understanding their state, and moving between workspaces and devices.
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
Core development continues through improvements to reliability, performance, interaction design, and workflows with broad value. A lean core takes ongoing work.
|
||||
|
||||
## Current state (May 2026)
|
||||
Every addition also has a lasting cost. It needs a place in the interface, an understandable relationship with existing behavior, testing across affected platforms, and maintenance as the rest of the product changes. A feature can work exactly as intended and still be the wrong addition.
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
|
||||
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (workspaces, create/detach agent, schedules, heartbeats, terminals, workspace renaming)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
The preference is for a small number of well-shaped capabilities that work together. Before adding a specialized workflow, consider whether an existing capability can serve it or whether a reusable extension point would let people build it themselves.
|
||||
|
||||
The boundary can evolve. Some built-in capabilities may eventually make more sense as plugins. Any such move needs to preserve a coherent default experience and account for people who already depend on it.
|
||||
|
||||
## Extensibility and composition
|
||||
|
||||
Plugins are a central part of how Paseo gains capability.
|
||||
|
||||
Someone who needs a different agent provider, a specialized workspace panel, or a custom command should be able to build and share it independently. They can make the choices that fit their users and maintain the integration on their own schedule.
|
||||
|
||||
Paseo's role is to provide interfaces that make those contributions possible across the daemon and connected clients. The [plugin documentation](https://paseo.sh/docs/plugins) describes what each released API supports.
|
||||
|
||||
The [SDK](https://paseo.sh/docs/sdk) lets applications and services use the daemon directly. A separate dashboard, automation service, or client can build on the same agent infrastructure. Those projects do not all need to become features of the main app.
|
||||
|
||||
Composition means useful pieces can be combined in ways the maintainer did not have to predict. Keeping the daemon independent, offering multiple connection methods, and exposing agent operations through APIs gives people room to choose those combinations.
|
||||
|
||||
The extension interfaces need the same care as the core experience. They should be understandable, reusable, and maintainable. New extension points should follow concrete workflows that need them.
|
||||
|
||||
## How Paseo develops
|
||||
|
||||
Product, design, architecture, and workflow decisions remain with the maintainer.
|
||||
|
||||
Those decisions draw on the history of the product: why a workflow has its current shape, what previous approaches taught us, how it interacts with the rest of the system, and where it is intended to go. Documentation shares that context, but familiarity also develops through sustained use and maintenance.
|
||||
|
||||
Development starts with the smallest useful change that can make a substantial difference. After shipping it, leave room to learn:
|
||||
|
||||
- Can people discover and understand it?
|
||||
- Does it work well in their actual workflows?
|
||||
- What friction or missing capability appears with repeated use?
|
||||
- Does the next addition improve the shared experience or belong in a plugin?
|
||||
|
||||
That feedback guides the next step. Expanding a workflow too far before people use it creates commitments that are difficult to reverse. Users build habits and integrations around what ships, so removing features or changing interfaces has a real cost.
|
||||
|
||||
Writing an implementation is one part of this process. Product judgment, design, QA, review, and learning from use determine how quickly a change can responsibly become part of Paseo.
|
||||
|
||||
## An ecosystem people can build on
|
||||
|
||||
People contribute through reproducible bug reports, testing, workflow discussions, documentation, plugins, integrations, and helping each other.
|
||||
|
||||
A plugin can solve a problem for its users without adding that workflow to everyone's default experience. An alternative client can explore a different interface while using the same daemon. These projects let people take ownership of the experience they want.
|
||||
|
||||
The aim is an ecosystem that can develop independently of the maintainer's ability to review core changes. Its value comes from useful things people build, maintain, and share.
|
||||
|
||||
Paseo is an independent project. Sustainable development, a coherent product, and the freedom to keep building are priorities. See [Contributing](../CONTRIBUTING.md) for how to participate.
|
||||
|
||||
@@ -38,6 +38,18 @@ The app checks for the capability and either runs the feature or tells the user
|
||||
|
||||
Existing functionality keeps working across versions because of the protocol contract. Gating a new feature never substitutes for that.
|
||||
|
||||
## Client capability ownership
|
||||
|
||||
The client package advertises the protocol behavior it implements. Add each new capability to
|
||||
its exhaustive defaults and implement the associated subscription or decoding behavior there.
|
||||
The app, CLI, and plugins inherit those defaults; they supply only host resources such as browser
|
||||
automation, or explicit overrides. A schema accepting a message does not establish support for
|
||||
its delivery semantics.
|
||||
|
||||
Connecting creates no timeline or event demand. Client subscriptions own their network membership,
|
||||
release it on unsubscribe, and restore it after reconnect. Raw message observers inspect traffic
|
||||
without requesting streams. Application caches and which agents are visible remain caller-owned.
|
||||
|
||||
## Every shim is tagged and dated
|
||||
|
||||
A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go:
|
||||
|
||||
@@ -486,6 +486,8 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
No prefix (`v`), no extra text. `Release Notes Sync` matches the `## X.Y.Z` (or `## X.Y.Z-beta.N`) line for the pushed tag to extract the version. A malformed heading breaks the release-notes sync for that tag.
|
||||
|
||||
`CHANGELOG.md` on `main` is also what the app's **What's new** sheet fetches and renders, so the file is a shipped product surface, not just a release input. `##` starts a release and `###` starts a section; the app reads section titles from the document, so renaming or adding one needs no app change. Everything under a section is rendered as Markdown: prose, lists, links, inline code, fenced code, block quotes, tables, and images. Raw HTML does not render — the shared Markdown parser runs with `html: false`, so a `<video>`, `<iframe>` or `<embed>` tag reaches the reader as visible markup. Keep media out of the changelog, or link to it. A GitHub callout renders as a block quote with its `[!NOTE]` marker still in the text. A release entry is what a user reads on a phone the moment they are offered the update — write it for them.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and every entry in the current beta series.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-40KXWVtW9wgHYJmn7XrLHzHWdOJiSCiajj9duHnDfXA=
|
||||
sha256-8P7kk9B2A81e13IIyyfo8fIoNGlrutfV/WGKAFHscUc=
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { verifyDelayedWorkspaceCreation } from "../support/helpers/new-workspace-navigation";
|
||||
|
||||
test.describe("Delayed workspace creation", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
for (const launch of ["chat", "terminal", "empty"] as const) {
|
||||
test(`${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave");
|
||||
});
|
||||
test(`worktree ${launch}: keeps the workspace chosen during creation`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "leave", "worktree");
|
||||
});
|
||||
test(`${launch}: opens the created workspace when the user stays`, async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, launch, "stay");
|
||||
});
|
||||
}
|
||||
|
||||
test("preserves a newer draft opened while creation is pending", async ({ page }) => {
|
||||
await verifyDelayedWorkspaceCreation(page, "chat", "new-draft");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonAuthorExample } from "../support/helpers/plugin-button-example";
|
||||
|
||||
test("the button author example shows each capability without a competing overflow button", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await withButtonAuthorExample(page, info, async (buttons) => {
|
||||
await buttons.refreshFromHeaderAndComposer();
|
||||
await buttons.useMenusAndToggleButtons();
|
||||
await buttons.inspectLiveWorkspaceDetails();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { withButtonShowcase } from "../support/helpers/plugin-buttons";
|
||||
|
||||
test("plugin header and composer buttons share actions, menus, content, and updates", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await withButtonShowcase(page, testInfo, async (buttons) => {
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import {
|
||||
test,
|
||||
verifyProviderSettings,
|
||||
verifyNewWorkspaceModelIcon,
|
||||
verifyCompactModelIcon,
|
||||
verifyExistingAgentModelIcon,
|
||||
} from "../support/helpers/plugin-provider-icons";
|
||||
|
||||
test("plugin provider icons follow the model through settings and wide and compact composers", async ({
|
||||
providerIcons,
|
||||
}) => {
|
||||
await verifyProviderSettings(providerIcons);
|
||||
await verifyNewWorkspaceModelIcon(providerIcons);
|
||||
await verifyCompactModelIcon(providerIcons);
|
||||
await verifyExistingAgentModelIcon(providerIcons);
|
||||
});
|
||||
@@ -61,12 +61,6 @@ function SidebarCollisionSurface() {
|
||||
return <View><Text>Sidebar collision surface</Text></View>;
|
||||
}
|
||||
|
||||
function ComposerPill({ theme, workspaceId, agentId }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ title: value.title }));
|
||||
const agent = useAgent(agentId, (value) => ({ title: value.title }));
|
||||
return <><Icon name="Scan" size={14} color={theme.colors.foregroundMuted} /><Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, flexShrink: 1 }}>Review {workspace?.title}:{agent?.title}</Text></>;
|
||||
}
|
||||
|
||||
function contributeClient(client) {
|
||||
const pills = new Map();
|
||||
const remove = (agentId) => {
|
||||
@@ -81,23 +75,25 @@ function contributeClient(client) {
|
||||
const agent = update.agent;
|
||||
if (agent.title !== "Plugin panel context agent" || !agent.workspaceId) return;
|
||||
remove(agent.id);
|
||||
let removePill = () => {};
|
||||
removePill = client.addComposerPill({
|
||||
const pill = client.addComposerPill({
|
||||
id: "review",
|
||||
title: "Open composer review",
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
Component: ComposerPill,
|
||||
async onPress() {
|
||||
button: {
|
||||
title: "Open composer review",
|
||||
icon: "Scan",
|
||||
label: "Review",
|
||||
behavior: { kind: "action", async onPress() {
|
||||
await client.rpc(recordComposerOpen, { workspaceId: agent.workspaceId });
|
||||
removePill();
|
||||
pill.remove();
|
||||
client.openPanel("agent", {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.id,
|
||||
});
|
||||
} },
|
||||
},
|
||||
});
|
||||
pills.set(agent.id, removePill);
|
||||
pills.set(agent.id, () => pill.remove());
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
@@ -333,9 +329,7 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
const composerPill = page.getByRole("button", { name: "Open composer review" });
|
||||
await expect(composerPill).toContainText(
|
||||
"Review Unrelated title update:Plugin panel context agent",
|
||||
);
|
||||
await expect(composerPill).toContainText("Review");
|
||||
await capture(page, testInfo, "plugin-composer-pill-wide");
|
||||
await page.setViewportSize(COMPACT_VIEWPORT);
|
||||
await expect(page.getByRole("button", { name: "Open composer review" })).toBeVisible();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
import { openWhatsNew, release, serveChangelog } from "../support/helpers/changelog";
|
||||
|
||||
const DISCORD_DESTINATION =
|
||||
/^https:\/\/(?:discord\.gg\/jz8T2uahpH|discord\.com\/invite\/jz8T2uahpH)(?:[/?#]|$)/;
|
||||
@@ -41,7 +42,7 @@ async function expectExternalPage(
|
||||
await popup.close();
|
||||
}
|
||||
|
||||
test("opens troubleshooting, support, and release destinations", async ({ page }) => {
|
||||
test("opens troubleshooting and support destinations", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await expect(page.getByTestId("sidebar-help")).toBeVisible();
|
||||
|
||||
@@ -62,18 +63,60 @@ test("opens troubleshooting, support, and release destinations", async ({ page }
|
||||
await closeSheet(page, "keyboard-shortcuts-dialog");
|
||||
});
|
||||
|
||||
await test.step("opens support and release pages", async () => {
|
||||
await test.step("opens support pages", async () => {
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-discord", DISCORD_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-github", GITHUB_ISSUE_DESTINATION);
|
||||
|
||||
await openHelpMenu(page);
|
||||
await expectExternalPage(page, "sidebar-help-changelog", CHANGELOG_DESTINATION);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders the changelog in the app and links the website", async ({ page }) => {
|
||||
// A callout, a section name the app has never seen, and a fenced sample whose
|
||||
// contents look like a release heading.
|
||||
await serveChangelog(page, [
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 9.1.0 - 2026-03-04",
|
||||
"",
|
||||
"Headline release note.",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> Read this before upgrading.",
|
||||
"",
|
||||
"### Sparkles",
|
||||
"",
|
||||
"- Added a brand new thing",
|
||||
"",
|
||||
"```md",
|
||||
"## 0.0.0 - 1999-01-01",
|
||||
"```",
|
||||
"",
|
||||
"## 9.0.0 - 2026-02-01",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed an older thing",
|
||||
"",
|
||||
]);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const sheet = await openWhatsNew(page);
|
||||
const latest = release(sheet, "9.1.0");
|
||||
|
||||
await expect(latest.getByText("March 4, 2026", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Headline release note.")).toBeVisible();
|
||||
await expect(latest.getByText("Read this before upgrading.")).toBeVisible();
|
||||
await expect(latest.getByText("Sparkles", { exact: true })).toBeVisible();
|
||||
await expect(latest.getByText("Added a brand new thing")).toBeVisible();
|
||||
await expect(release(sheet, "9.0.0")).toBeVisible();
|
||||
await expect(release(sheet, "0.0.0")).toHaveCount(0);
|
||||
|
||||
await expectExternalPage(page, "changelog-open-website", CHANGELOG_DESTINATION);
|
||||
await closeSheet(page, "changelog-sheet");
|
||||
});
|
||||
|
||||
test("searches keyboard shortcuts from the sidebar help menu", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test as base, expect } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { closeMobileAgentSidebar, openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
interface FinishedWorkspaces {
|
||||
subject: MockAgentWorkspace;
|
||||
other: MockAgentWorkspace;
|
||||
}
|
||||
|
||||
const test = base.extend<{ workspaces: FinishedWorkspaces }>({
|
||||
workspaces: async ({ browserName: _browserName }, provide) => {
|
||||
const seeded: MockAgentWorkspace[] = [];
|
||||
async function finishedWorkspace(title: string) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "workspace-mark-unread-",
|
||||
title,
|
||||
initialPrompt: "Finish this turn.",
|
||||
});
|
||||
seeded.push(workspace);
|
||||
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
||||
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
|
||||
return workspace;
|
||||
}
|
||||
try {
|
||||
await provide({
|
||||
subject: await finishedWorkspace("Unread subject"),
|
||||
other: await finishedWorkspace("Other workspace"),
|
||||
});
|
||||
} finally {
|
||||
for (const workspace of seeded) await workspace.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function workspaceRow(page: Page, workspaceId: string) {
|
||||
return page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
}
|
||||
|
||||
async function openWorkspace(page: Page, workspaceId: string) {
|
||||
await workspaceRow(page, workspaceId).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
||||
}
|
||||
|
||||
async function chooseReadAction(page: Page, workspaceId: string, action: "read" | "unread") {
|
||||
await workspaceRow(page, workspaceId).hover();
|
||||
await page.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${workspaceId}`).click();
|
||||
const item = page.getByRole("menuitem", { name: `Mark as ${action}`, exact: true });
|
||||
await expect(item).toBeVisible();
|
||||
await item.click();
|
||||
}
|
||||
|
||||
async function markAsUnread(page: Page, workspaceId: string) {
|
||||
await chooseReadAction(page, workspaceId, "unread");
|
||||
await expectStatus(page, workspaceId, "attention");
|
||||
}
|
||||
|
||||
async function markAsRead(page: Page, workspaceId: string) {
|
||||
await chooseReadAction(page, workspaceId, "read");
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
}
|
||||
|
||||
async function expectStatus(page: Page, workspaceId: string, status: "done" | "attention") {
|
||||
await expect(
|
||||
workspaceRow(page, workspaceId).getByTestId(`workspace-status-indicator-${status}`),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function markBackgroundWorkspaceAndReopen(page: Page, workspaceId: string) {
|
||||
await test.step("background workspace gains green dot and clears when clicked", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await openWorkspace(page, workspaceId);
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndRead(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("leaving preserves manual unread until Mark as read", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await markAsRead(page, subject.workspaceId);
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReopen(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("leaving preserves manual unread until reopening", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function completeTurnAndLeave(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspace(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function openWorkspaceOnCompact(page: Page, workspaceId: string) {
|
||||
await openWorkspace(page, workspaceId);
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReadOnCompact(
|
||||
page: Page,
|
||||
{ subject, other }: FinishedWorkspaces,
|
||||
) {
|
||||
await test.step("leaving preserves manual unread until Mark as read", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await markAsRead(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function leaveMarkedWorkspaceAndReopenOnCompact(
|
||||
page: Page,
|
||||
{ subject, other }: FinishedWorkspaces,
|
||||
) {
|
||||
await test.step("leaving preserves manual unread until reopening", async () => {
|
||||
await markAsUnread(page, subject.workspaceId);
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, subject.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function completeTurnAndLeaveOnCompact(page: Page, { subject, other }: FinishedWorkspaces) {
|
||||
await test.step("ordinary completion still clears on departure", async () => {
|
||||
await closeMobileAgentSidebar(page);
|
||||
await subject.client.sendAgentMessage(subject.agentId, "Finish another turn.");
|
||||
await subject.client.waitForFinish(subject.agentId, 20_000);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, subject.workspaceId, "attention");
|
||||
await openWorkspaceOnCompact(page, other.workspaceId);
|
||||
await expectStatus(page, subject.workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
async function markBackgroundWorkspaceAndRead(page: Page, workspaceId: string) {
|
||||
await test.step("explicit Mark as read clears a background workspace", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await markAsRead(page, workspaceId);
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSelectedAgent(page: Page, agentId: string) {
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
}
|
||||
|
||||
async function addFinishedAgent(workspace: MockAgentWorkspace) {
|
||||
return workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.cwd,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Newest agent",
|
||||
modeId: "load-test",
|
||||
model: "e2e-fast-stream",
|
||||
});
|
||||
}
|
||||
|
||||
async function openCompactWorkspace(page: Page, workspace: MockAgentWorkspace) {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentRoute(page, workspace);
|
||||
await openMobileAgentSidebar(page);
|
||||
}
|
||||
|
||||
async function markUnreadThenResumeChat(page: Page, workspaceId: string) {
|
||||
await test.step("interacting with the visible chat clears manual unread", async () => {
|
||||
await markAsUnread(page, workspaceId);
|
||||
await closeMobileAgentSidebar(page);
|
||||
await page.getByRole("textbox", { name: "Message agent..." }).click();
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectStatus(page, workspaceId, "done");
|
||||
});
|
||||
}
|
||||
|
||||
test("manual unread survives departure and clears on reopening without changing normal completions", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await leaveMarkedWorkspaceAndRead(page, workspaces);
|
||||
await leaveMarkedWorkspaceAndReopen(page, workspaces);
|
||||
await completeTurnAndLeave(page, workspaces);
|
||||
await markBackgroundWorkspaceAndRead(page, workspaces.subject.workspaceId);
|
||||
});
|
||||
|
||||
test("clicking a multi-agent workspace reveals and clears its marked agent", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openAgentRoute(page, workspaces.subject);
|
||||
await expectSelectedAgent(page, workspaces.subject.agentId);
|
||||
await openWorkspace(page, workspaces.other.workspaceId);
|
||||
const newest = await addFinishedAgent(workspaces.subject);
|
||||
await markBackgroundWorkspaceAndReopen(page, workspaces.subject.workspaceId);
|
||||
await expectSelectedAgent(page, newest.id);
|
||||
});
|
||||
|
||||
test("manual unread survives leaving the current workspace on compact layout", async ({
|
||||
page,
|
||||
workspaces,
|
||||
}) => {
|
||||
await openCompactWorkspace(page, workspaces.subject);
|
||||
await leaveMarkedWorkspaceAndReadOnCompact(page, workspaces);
|
||||
await leaveMarkedWorkspaceAndReopenOnCompact(page, workspaces);
|
||||
await markUnreadThenResumeChat(page, workspaces.subject.workspaceId);
|
||||
await completeTurnAndLeaveOnCompact(page, workspaces);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { getServerId } from "../support/helpers/server-id";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { escapeRegex } from "../support/helpers/regex";
|
||||
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
const GITHUB_REMOTE_URL = "https://github.com/test-owner/test-repo.git";
|
||||
|
||||
@@ -48,6 +49,20 @@ async function waitForSidebarWorkspace(page: import("@playwright/test").Page, wo
|
||||
return row;
|
||||
}
|
||||
|
||||
async function openWorkspaceReadAction(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
action: "read" | "unread",
|
||||
) {
|
||||
const workspaceKey = `${getServerId()}:${workspaceId}`;
|
||||
const row = await waitForSidebarWorkspace(page, workspaceId);
|
||||
await row.hover();
|
||||
await page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`).click();
|
||||
const item = page.getByTestId(`sidebar-workspace-menu-mark-as-${action}-${workspaceKey}`);
|
||||
await expect(item).toBeVisible({ timeout: 30_000 });
|
||||
return item;
|
||||
}
|
||||
|
||||
async function openWorkspaceHoverCard(page: import("@playwright/test").Page, workspaceId: string) {
|
||||
const row = await waitForSidebarWorkspace(page, workspaceId);
|
||||
await row.hover();
|
||||
@@ -187,6 +202,32 @@ test.describe("Sidebar workspace list", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("marks a finished workspace unread until it is opened again", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "sidebar-mark-unread-",
|
||||
title: "Mark unread",
|
||||
initialPrompt: "Finish this test turn.",
|
||||
});
|
||||
|
||||
try {
|
||||
await workspace.client.waitForFinish(workspace.agentId, 20_000);
|
||||
await workspace.client.clearWorkspaceAttention(workspace.workspaceId);
|
||||
expect(workspace.client.getLastServerInfoMessage()?.features?.workspaceMarkUnread).toBe(true);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const row = await waitForSidebarWorkspace(page, workspace.workspaceId);
|
||||
await expect(row.getByTestId("workspace-status-indicator-done")).toBeVisible();
|
||||
await (await openWorkspaceReadAction(page, workspace.workspaceId, "unread")).click();
|
||||
await openWorkspaceReadAction(page, workspace.workspaceId, "read");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await openWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await openWorkspaceReadAction(page, workspace.workspaceId, "unread");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Paseo-owned worktree hover card shows the worktree directory name", async ({ page }) => {
|
||||
await withPaseoOwnedWorktree(async ({ projectName, workspaceId, worktreeSlug }) => {
|
||||
await gotoAppShell(page);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { renameModalInput, renameModalSubmit } from "../support/helpers/rename";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import { selectWorkspaceInSidebar } from "../support/helpers/sidebar";
|
||||
|
||||
function terminalTab(page: Page, terminalId: string) {
|
||||
// Terminal tab chips have no tab role; their persistent identity survives title loading.
|
||||
return page.getByTestId(`workspace-tab-terminal_${terminalId}`).filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
async function createNamedTerminal(page: Page, workspace: SeededWorkspace, title: string) {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
const list = () =>
|
||||
workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
await expect.poll(async () => (await list()).terminals.length).toBe(1);
|
||||
const terminalId = (await list()).terminals[0]!.id;
|
||||
await terminalTab(page, terminalId).click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click();
|
||||
const modal = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
await renameModalInput(page, modal).fill(title);
|
||||
await renameModalSubmit(page, modal).click();
|
||||
await expect(renameModalInput(page, modal)).toHaveCount(0);
|
||||
await expect(terminalTab(page, terminalId)).toHaveText(title);
|
||||
return terminalId;
|
||||
}
|
||||
|
||||
async function recordRestoredTabText(page: Page, terminalId: string) {
|
||||
// Install before the next document loads: a final-state assertion would miss the flash.
|
||||
await page.addInitScript((id) => {
|
||||
const samples: string[] = [];
|
||||
Object.assign(window, { terminalTitleSamples: samples });
|
||||
new MutationObserver(() => {
|
||||
const tab = document.querySelector(`[data-testid="workspace-tab-terminal_${id}"]`);
|
||||
if (!tab) return;
|
||||
const text = (tab.textContent ?? "").trim();
|
||||
if (samples.at(-1) !== text) samples.push(text);
|
||||
}).observe(document, { subtree: true, childList: true, characterData: true });
|
||||
}, terminalId);
|
||||
}
|
||||
|
||||
async function expectRestoredTitle(page: Page, terminalId: string, title: string, stage: string) {
|
||||
await expect(terminalTab(page, terminalId)).toHaveText(title, { timeout: 30_000 });
|
||||
const samples = await page.evaluate(
|
||||
() => (window as unknown as { terminalTitleSamples: string[] }).terminalTitleSamples,
|
||||
);
|
||||
await test.info().attach(`${stage}-tab-text`, {
|
||||
body: JSON.stringify(samples),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(samples).toContain(title);
|
||||
expect(samples).not.toContain("Terminal");
|
||||
}
|
||||
|
||||
test("restored terminal titles do not flash a default name while loading", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "terminal-title-loading-", git: false });
|
||||
const other = await seedWorkspace({ repoPrefix: "terminal-title-other-", git: false });
|
||||
try {
|
||||
const title = "My named terminal";
|
||||
const terminalId = await createNamedTerminal(page, workspace, title);
|
||||
await recordRestoredTabText(page, terminalId);
|
||||
|
||||
await test.step("reload with a terminal tab restored", async () => {
|
||||
await page.reload();
|
||||
await expectRestoredTitle(page, terminalId, title, "reload");
|
||||
});
|
||||
|
||||
await test.step("first workspace visit in a fresh page session", async () => {
|
||||
await gotoWorkspace(page, other.workspaceId);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await expectRestoredTitle(page, terminalId, title, "first-visit");
|
||||
});
|
||||
|
||||
await test.step("return to the workspace with its titles already loaded", async () => {
|
||||
await selectWorkspaceInSidebar(page, other.workspaceId);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
await expectRestoredTitle(page, terminalId, title, "return-visit");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await other.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
const CHANGELOG_SOURCE_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
/**
|
||||
* Serves a changelog to the app instead of the repository's.
|
||||
*
|
||||
* The sheet renders whatever the document contains, so a fixture is how a test
|
||||
* states which authoring shapes it cares about.
|
||||
*/
|
||||
export async function serveChangelog(page: Page, lines: string[]): Promise<void> {
|
||||
await page.route(CHANGELOG_SOURCE_URL, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "text/plain", body: lines.join("\n") }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openWhatsNew(page: Page): Promise<Locator> {
|
||||
await page.getByTestId("sidebar-help").click();
|
||||
await expect(page.getByTestId("sidebar-help-menu")).toBeVisible();
|
||||
await page.getByTestId("sidebar-help-changelog").click();
|
||||
|
||||
const sheet = page.getByTestId("changelog-sheet");
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export function release(sheet: Locator, version: string): Locator {
|
||||
return sheet.getByTestId(`changelog-release-${version}`);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { expect, test, type Page } from "../fixtures";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollTimelineToOldestLoadedEdge } from "./timeline-pagination";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
countWorkspaceAgents,
|
||||
countWorkspaceTerminals,
|
||||
delayBrowserWorkspaceCreatedResponse,
|
||||
expectNewWorkspaceDraft,
|
||||
fillNewWorkspaceDraft,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
selectWorkspaceIsolation,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
waitForCreatedWorkspace,
|
||||
} from "./new-workspace";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import { getServerId } from "./server-id";
|
||||
import {
|
||||
expectTerminalOutputContains,
|
||||
expectWorkspaceOpensWithTerminalTab,
|
||||
fillTerminalPrompt,
|
||||
seedTerminalProfiles,
|
||||
selectLaunchOption,
|
||||
submitTerminalLaunch,
|
||||
type TerminalProfile,
|
||||
} from "./new-workspace-launch";
|
||||
import { switchWorkspaceViaSidebar, waitForSidebarHydration } from "./workspace-ui";
|
||||
import { dropFileOnComposer, expectAttachmentPill } from "./composer";
|
||||
|
||||
const PROMPT = "Hello from the navigation guard";
|
||||
const NEXT_PROMPT = "Keep this newer draft";
|
||||
const CONTEXT = {
|
||||
name: "context.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from('{"preserve":"attachment"}'),
|
||||
};
|
||||
const PROFILE: TerminalProfile = {
|
||||
id: "e2e-nav-guard-echo",
|
||||
name: "Nav Guard Echo",
|
||||
command: "/bin/sh",
|
||||
args: ["-c", 'echo captured: "$0"; sleep 120', "{{{prompt}}}"],
|
||||
};
|
||||
|
||||
export async function verifyDelayedWorkspaceCreation(
|
||||
page: Page,
|
||||
launch: "chat" | "terminal" | "empty",
|
||||
destination: "leave" | "stay" | "new-draft",
|
||||
isolation: "local" | "worktree" = "local",
|
||||
): Promise<void> {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("workspace-focus-");
|
||||
const profileSeed = await seedTerminalProfiles([PROFILE]);
|
||||
const delay = await delayBrowserWorkspaceCreatedResponse(page);
|
||||
const serverId = getServerId();
|
||||
let localWorkspaceId: string | undefined;
|
||||
let createdDirectory: string | undefined;
|
||||
let newDraftRoute = "";
|
||||
|
||||
try {
|
||||
const project = await openProjectViaDaemon(client, repo.path);
|
||||
localWorkspaceId = project.workspaceId;
|
||||
const knownIds = new Set((await client.fetchWorkspaces()).entries.map((entry) => entry.id));
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
|
||||
await test.step("Submit workspace creation with the requested content", async () => {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await selectWorkspaceIsolation(page, isolation);
|
||||
if (launch === "chat") {
|
||||
await dropFileOnComposer(page, CONTEXT);
|
||||
await expectAttachmentPill(page, "composer-file-attachment-pill");
|
||||
await submitNewWorkspacePrompt(page, PROMPT);
|
||||
} else if (launch === "terminal") {
|
||||
await selectLaunchOption(page, PROFILE.id);
|
||||
await fillTerminalPrompt(page, PROMPT);
|
||||
await submitTerminalLaunch(page);
|
||||
} else {
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
}
|
||||
await delay.waitForCreateRequest();
|
||||
});
|
||||
|
||||
await test.step("Choose where to work while the creation response is held", async () => {
|
||||
if (destination !== "stay") {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
}
|
||||
if (destination === "new-draft") {
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await fillNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
const url = new URL(page.url());
|
||||
newDraftRoute = url.pathname + url.search;
|
||||
}
|
||||
// Recording pacing only; the response is held deterministically above.
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
delay.release();
|
||||
});
|
||||
|
||||
const created = await waitForCreatedWorkspace(client, knownIds);
|
||||
createdDirectory = created.workspaceDirectory;
|
||||
expect(created.workspaceKind).toBe(isolation === "worktree" ? "worktree" : "local_checkout");
|
||||
await test.step("Completion creates exactly the requested resource and preserves focus", async () => {
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
// A negative navigation assertion needs an observation window after completion.
|
||||
await page.waitForTimeout(5000);
|
||||
const expectedRoute =
|
||||
destination === "new-draft"
|
||||
? newDraftRoute
|
||||
: buildHostWorkspaceRoute(
|
||||
serverId,
|
||||
destination === "stay" ? created.id : project.workspaceId,
|
||||
);
|
||||
await expectAppRoute(page, expectedRoute);
|
||||
if (destination === "new-draft") await expectNewWorkspaceDraft(page, NEXT_PROMPT);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
const upload = delay.agentRequests[0]?.attachments?.find(
|
||||
(item) => item.type === "uploaded_file",
|
||||
);
|
||||
if (!upload) throw new Error("The create request did not contain the submitted file");
|
||||
expect(await readFile(upload.path)).toEqual(CONTEXT.buffer);
|
||||
expect(delay.agentRequests[0]).toMatchObject({
|
||||
workspaceId: created.id,
|
||||
initialPrompt: PROMPT,
|
||||
config: {
|
||||
provider: "mock",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
thinkingOptionId: "low",
|
||||
cwd: created.workspaceDirectory,
|
||||
},
|
||||
attachments: [
|
||||
expect.objectContaining({
|
||||
fileName: CONTEXT.name,
|
||||
mimeType: CONTEXT.mimeType,
|
||||
size: CONTEXT.buffer.length,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Visit the new workspace and return without submitting a second draft", async () => {
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
if (launch === "chat") {
|
||||
await scrollTimelineToOldestLoadedEdge(page);
|
||||
await expect(page.getByText(PROMPT, { exact: true }).first()).toBeVisible();
|
||||
} else if (launch === "terminal") {
|
||||
await expectWorkspaceOpensWithTerminalTab(page);
|
||||
await expectTerminalOutputContains(page, `captured: ${PROMPT}`);
|
||||
}
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: project.workspaceId });
|
||||
await switchWorkspaceViaSidebar({ page, serverId, workspaceId: created.id });
|
||||
await expect
|
||||
.poll(() => countWorkspaceAgents(client, created.id))
|
||||
.toBe(launch === "chat" ? 1 : 0);
|
||||
await expect
|
||||
.poll(() => countWorkspaceTerminals(client, created.id))
|
||||
.toBe(launch === "terminal" ? 1 : 0);
|
||||
if (launch === "chat") {
|
||||
expect(delay.agentRequests).toHaveLength(1);
|
||||
await openNewWorkspaceComposer(page, project);
|
||||
await expectNewWorkspaceDraft(page, destination === "new-draft" ? NEXT_PROMPT : "");
|
||||
}
|
||||
if (process.env.E2E_RECORD_VIDEO === "1") {
|
||||
await page.mouse.move(1100, 80);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await page.screenshot({ path: test.info().outputPath("before-cleanup.png") });
|
||||
console.error(error);
|
||||
throw error;
|
||||
} finally {
|
||||
delay.release();
|
||||
if (createdDirectory) await archiveWorkspaceFromDaemon(client, createdDirectory);
|
||||
if (localWorkspaceId) await archiveLocalWorkspaceFromDaemon(client, localWorkspaceId);
|
||||
await profileSeed.restore();
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import type { CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -16,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "createWorkspace"
|
||||
| "fetchAgents"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
@@ -24,6 +26,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "enablePlugin"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "listProjects"
|
||||
| "listTerminals"
|
||||
| "on"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
@@ -622,3 +625,120 @@ export async function delayBrowserAgentCreatedStatus(
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceCreatedDelayControl {
|
||||
agentRequests: readonly CreateAgentRequestMessage[];
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds `workspace.create.response` so a test can act as the user does while the daemon is still
|
||||
* running `git worktree add` — most importantly, navigate somewhere else.
|
||||
*/
|
||||
export async function delayBrowserWorkspaceCreatedResponse(
|
||||
page: Page,
|
||||
): Promise<WorkspaceCreatedDelayControl> {
|
||||
const daemonPortPattern = daemonWsRoutePattern();
|
||||
const agentRequests: CreateAgentRequestMessage[] = [];
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request")
|
||||
agentRequests.push(sessionMessage as CreateAgentRequestMessage);
|
||||
if (sessionMessage?.type === "workspace.create.request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "workspace.create.response" &&
|
||||
typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (requestId && createRequestIds.has(requestId) && !releaseRequested) {
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
agentRequests,
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
};
|
||||
}
|
||||
|
||||
type WorkspaceEntry = Awaited<
|
||||
ReturnType<NewWorkspaceDaemonClient["fetchWorkspaces"]>
|
||||
>["entries"][number];
|
||||
|
||||
/**
|
||||
* Waits for a workspace the caller did not already know about, and returns it. Tests that create a
|
||||
* workspace without navigating to it have no route to assert on, so the daemon's own list is the
|
||||
* signal that creation finished.
|
||||
*/
|
||||
export async function waitForCreatedWorkspace(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
knownWorkspaceIds: ReadonlySet<string>,
|
||||
options?: { timeout?: number },
|
||||
): Promise<WorkspaceEntry> {
|
||||
let created: WorkspaceEntry | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
created = payload.entries.find((entry) => !knownWorkspaceIds.has(entry.id));
|
||||
return created !== undefined;
|
||||
},
|
||||
{ timeout: options?.timeout ?? 60_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
if (!created) {
|
||||
throw new Error("Workspace list reported a new workspace but it could not be read back");
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function countWorkspaceAgents(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
return payload.entries.filter((entry) => entry.agent.workspaceId === workspaceId).length;
|
||||
}
|
||||
|
||||
export async function countWorkspaceTerminals(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<number> {
|
||||
const payload = await client.listTerminals(undefined, undefined, { workspaceId });
|
||||
return payload.terminals.length;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
function headerButtons(page: Page) {
|
||||
return page.getByRole("toolbar", { name: "Workspace actions", exact: true }).getByRole("button");
|
||||
}
|
||||
|
||||
async function exampleCommand(page: Page, name: string) {
|
||||
await page.setViewportSize(WIDE);
|
||||
const panel = await openCommandCenter(page);
|
||||
const title = `Button examples: ${name}`;
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
name: string,
|
||||
subject: Locator = headerButtons(page),
|
||||
) {
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await info.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function closeDetails(page: Page) {
|
||||
await page.getByRole("button", { name: "Close workspace details", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function withButtonAuthorExample(
|
||||
page: Page,
|
||||
info: TestInfo,
|
||||
run: (buttons: {
|
||||
refreshFromHeaderAndComposer(): Promise<void>;
|
||||
useMenusAndToggleButtons(): Promise<void>;
|
||||
inspectLiveWorkspaceDetails(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "button-example-",
|
||||
title: "Button examples",
|
||||
});
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Example agent",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("buttons");
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
|
||||
await run({
|
||||
refreshFromHeaderAndComposer: () =>
|
||||
test.step("icon-only header action and labeled composer action update in place", async () => {
|
||||
await exampleCommand(page, "action");
|
||||
await headerButtons(page).click();
|
||||
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
|
||||
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Refresh context", exact: true }),
|
||||
).toContainText("Refreshed · 2");
|
||||
await capture(page, info, "01-actions");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await capture(page, info, "02-compact-actions");
|
||||
}),
|
||||
useMenusAndToggleButtons: () =>
|
||||
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
|
||||
await exampleCommand(page, "menu");
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Publish (unavailable in example)", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"03-header-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Workspace details", exact: true }).click();
|
||||
await expect(page.getByText("Button examples", { exact: true }).last()).toBeVisible();
|
||||
await closeDetails(page);
|
||||
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"04-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Hide example buttons", exact: true }).click();
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer tools", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await exampleCommand(page, "show");
|
||||
await exampleCommand(page, "disable");
|
||||
await expect(headerButtons(page)).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer tools", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, info, "05-disabled");
|
||||
await exampleCommand(page, "enable");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
).toBeInViewport();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"06-compact-menu",
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Refresh workspace", exact: true }).click();
|
||||
}),
|
||||
inspectLiveWorkspaceDetails: () =>
|
||||
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
|
||||
await exampleCommand(page, "popover");
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"07-header-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await client.setWorkspaceTitle(workspace.workspaceId, "Renamed from the daemon");
|
||||
await expect(
|
||||
page.getByText("Renamed from the daemon", { exact: true }).last(),
|
||||
).toBeVisible();
|
||||
await closeDetails(page);
|
||||
await page.getByRole("button", { name: "Context details", exact: true }).click();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"08-composer-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await closeDetails(page);
|
||||
await page.setViewportSize(COMPACT);
|
||||
await page.getByRole("button", { name: "Context details", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
).toBeInViewport();
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"09-compact-popover",
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
);
|
||||
await closeDetails(page);
|
||||
await client.disablePlugin("button-examples");
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Context details", exact: true }),
|
||||
).toHaveCount(0);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin("button-examples");
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
import { seedWorkspace } from "./seed-client";
|
||||
import { pluginRequirements } from "./plugin-fixture";
|
||||
import { waitForSettledPosition } from "./sheet-layout";
|
||||
|
||||
const PLUGIN_ID = "button-showcase";
|
||||
const WIDE = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
function clientSource(workspaceId: string, agentId: string): string {
|
||||
return `import React from "react";
|
||||
import { Text, View, Pressable } from "react-native";
|
||||
import { useWorkspace, useRpc } from "@getpaseo/plugin/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { summary } from "./shared/rpc";
|
||||
|
||||
function StatusIcon({ size, theme }) {
|
||||
return <View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: theme.colors.statusSuccess }} />;
|
||||
}
|
||||
|
||||
function Details({ workspaceId, theme, layout, close }) {
|
||||
const workspace = useWorkspace(workspaceId, (workspace) => workspace.name);
|
||||
const rpc = useRpc(summary);
|
||||
const query = useQuery({ queryKey: ["summary"], queryFn: () => rpc({}) });
|
||||
return <View style={{ gap: 12 }}>
|
||||
<Text style={{ color: theme.colors.foreground, fontSize: 18 }}>Deployment status</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>{workspace}</Text>
|
||||
<Text style={{ color: theme.colors.statusSuccess }}>{query.data?.message ?? "Loading deployment..."}</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>3 checks passed · Production</Text>
|
||||
<Text style={{ color: theme.colors.foregroundMuted }}>Presentation: {layout.compact ? "compact" : "wide"}</Text>
|
||||
<Pressable accessibilityRole="button" accessibilityLabel="Close deployment details" onPress={close} style={{ padding: 8, borderRadius: 6, backgroundColor: theme.colors.surface2 }}><Text style={{ color: theme.colors.foreground }}>Done</Text></Pressable>
|
||||
</View>;
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
const workspaceId = ${JSON.stringify(workspaceId)};
|
||||
const agentId = ${JSON.stringify(agentId)};
|
||||
const registrations = [];
|
||||
let finish = () => {};
|
||||
let finishReview = () => {};
|
||||
let attempts = 0;
|
||||
const action = { kind: "action", async onPress() {
|
||||
if (++attempts === 1) throw new Error("Deployment failed. Try again.");
|
||||
await new Promise((resolve) => { finish = resolve; });
|
||||
deploy.update({ label: "Deployed" });
|
||||
} };
|
||||
const header = (id, button) => { const registration = client.addHeaderButton({ id, workspaceId, button }); registrations.push(registration); return registration; };
|
||||
const pill = (id, button) => { const registration = client.addComposerPill({ id, workspaceId, agentId, button }); registrations.push(registration); return registration; };
|
||||
const menu = { kind: "menu", items: [
|
||||
{ kind: "item", id: "run", title: "Run checks", icon: "Play", behavior: { kind: "action", onPress() { checks.update({ label: "Checks passed" }); } } },
|
||||
{ kind: "separator", id: "divider" },
|
||||
{ kind: "item", id: "environment", title: "Environment", behavior: { kind: "menu", items: [
|
||||
{ kind: "item", id: "staging", title: "Staging", icon: "FlaskConical", behavior: { kind: "action", onPress() {} } },
|
||||
{ kind: "item", id: "production", title: "Production", icon: "Globe", disabled: true, behavior: { kind: "action", onPress() {} } },
|
||||
] } },
|
||||
{ kind: "item", id: "details", title: "Deployment details", icon: "Activity", behavior: { kind: "popover", Content: Details } },
|
||||
] };
|
||||
const deploy = header("deploy", { title: "Deploy application", icon: "Rocket", label: "Deploy", behavior: action });
|
||||
const checks = header("checks", { title: "Header checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
header("status", { title: "Header status", icon: StatusIcon, label: "Healthy", behavior: { kind: "popover", Content: Details } });
|
||||
header("logs", { title: "Open deployment logs", icon: "ScrollText", behavior: { kind: "popover", Content: Details } });
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
command("hide-deploy", "Hide deployment button", () => deploy.update({ visible: false }));
|
||||
command("show-deploy", "Show deployment button", () => deploy.update({ visible: true }));
|
||||
command("hide", "Hide review button", () => review.update({ visible: false }));
|
||||
command("show", "Show review button", () => review.update({ visible: true, label: "Review ready" }));
|
||||
command("disable", "Disable example buttons", () => { deploy.update({ disabled: true }); review.update({ disabled: true }); });
|
||||
command("enable", "Enable example buttons", () => { deploy.update({ disabled: false }); review.update({ disabled: false }); });
|
||||
command("icon-only", "Use icon-only header", () => deploy.update({ label: undefined }));
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const unsubscribe = client.paseo.workspaces.ref(workspaceId).subscribe((update) => {
|
||||
if (update.kind !== "upsert") return;
|
||||
deploy.update({ visible: update.workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: update.workspace.name !== "Hide composer menu" });
|
||||
});
|
||||
return () => { unsubscribe(); finish(); finishReview(); for (const registration of registrations) registration.remove(); };
|
||||
}`;
|
||||
}
|
||||
|
||||
async function installShowcase(workspaceId: string, agentId: string) {
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const config = await client.getDaemonConfig();
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-buttons-"));
|
||||
await mkdir(path.join(directory, "shared"));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), clientSource(workspaceId, agentId));
|
||||
await writeFile(
|
||||
path.join(directory, "shared/rpc.ts"),
|
||||
`import { defineRpc } from "@getpaseo/plugin"; import { z } from "zod"; export const summary = defineRpc({ name: "summary", input: z.object({}), output: z.object({ message: z.string() }) });`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, "index.server.ts"),
|
||||
`import { summary } from "./shared/rpc"; export default function contribute(server) { server.handle(summary, async () => ({ message: "All systems operational" })); return () => {}; }`,
|
||||
);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
return {
|
||||
disable: () => client.disablePlugin(PLUGIN_ID),
|
||||
setTitle: (title: string) => client.setWorkspaceTitle(workspaceId, title),
|
||||
async cleanup() {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: config.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function press(page: Page, title: string) {
|
||||
await page.getByRole("button", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function openHeaderOverflow(page: Page) {
|
||||
await page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
async function choose(page: Page, title: string) {
|
||||
await page.getByRole("menuitem", { name: title, exact: true }).click();
|
||||
}
|
||||
|
||||
async function command(page: Page, title: string) {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await panel.getByRole("button", { name: title, exact: true }).click();
|
||||
await expect(panel).not.toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
subject: Locator = page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
) {
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: file, animations: "disabled" });
|
||||
await testInfo.attach(name, { path: file, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectDetails(page: Page) {
|
||||
await expect(page.getByText("All systems operational", { exact: true })).toBeInViewport();
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
await expect(title).toHaveCSS("text-overflow", "ellipsis");
|
||||
await expect(subtitle).toHaveCSS("text-overflow", "ellipsis");
|
||||
// The text itself must overflow its box, rather than being clipped by an ancestor.
|
||||
await expect
|
||||
.poll(() => title.evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() => subtitle.evaluate((element) => element.scrollWidth > element.clientWidth))
|
||||
.toBe(true);
|
||||
const titleBox = await title.boundingBox();
|
||||
const subtitleBox = await subtitle.boundingBox();
|
||||
const actionsBox = await page
|
||||
.getByRole("button", { name: "Workspace actions", exact: true })
|
||||
.boundingBox();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(subtitleBox).not.toBeNull();
|
||||
expect(actionsBox).not.toBeNull();
|
||||
expect(titleBox!.x + titleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
|
||||
expect(subtitleBox!.x + subtitleBox!.width).toBeLessThanOrEqual(actionsBox!.x);
|
||||
const hostIcon = page.getByLabel("localhost", { exact: true }).locator("svg");
|
||||
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
|
||||
}
|
||||
|
||||
async function holdButton(page: Page, button: Locator) {
|
||||
await button.hover();
|
||||
await page.mouse.down();
|
||||
}
|
||||
|
||||
async function openCompactOverflowWithHeaderChrome(page: Page, testInfo: TestInfo) {
|
||||
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
|
||||
const action = page.getByRole("button", { name: "Deploy application", exact: true });
|
||||
const overflow = page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true });
|
||||
await expect(action).toHaveCSS("border-top-width", "0px");
|
||||
await expect(overflow).toHaveCSS("border-top-width", "0px");
|
||||
await hamburger.hover();
|
||||
const highlight = await hamburger.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await action.hover();
|
||||
await expect(action).toHaveCSS("background-color", highlight);
|
||||
await holdButton(page, overflow);
|
||||
await expect(overflow).toHaveCSS("background-color", highlight);
|
||||
await capture(page, testInfo, "12c-compact-overflow-pressed");
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function withButtonShowcase(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
}) => Promise<void>,
|
||||
) {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-buttons-" });
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Button showcase",
|
||||
model: "ten-second-stream",
|
||||
modeId: "load-test",
|
||||
});
|
||||
const showcase = await installShowcase(workspace.workspaceId, agent.id);
|
||||
try {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await page.goto(buildAgentRoute(workspace.workspaceId, agent.id));
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toBeVisible();
|
||||
|
||||
await run({
|
||||
openWideMenusAndPopovers: () =>
|
||||
test.step("wide buttons have placement-owned labels and chevrons", async () => {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-wide-buttons");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"02-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Environment");
|
||||
await expect(page.getByRole("menuitem", { name: "Staging", exact: true })).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"03-header-submenu",
|
||||
page.getByRole("menuitem", { name: "Staging", exact: true }),
|
||||
);
|
||||
await choose(page, "Staging");
|
||||
await openHeaderOverflow(page);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"03b-wide-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Open deployment logs");
|
||||
await expectDetails(page);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"04-header-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"05-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"06-composer-menu-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"07-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
}),
|
||||
runAndUpdateActions: () =>
|
||||
test.step("actions fail visibly, retry, and remain busy until completion", async () => {
|
||||
await press(page, "Deploy application");
|
||||
await expect(
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"08-action-error",
|
||||
page.getByText("Deployment failed. Try again.", { exact: true }),
|
||||
);
|
||||
await press(page, "Deploy application");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09-action-pending");
|
||||
await command(page, "Finish deployment");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Deploy application", exact: true }),
|
||||
).toContainText("Deployed");
|
||||
await press(page, "Run review");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "09b-composer-pending");
|
||||
await command(page, "Finish review");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
|
||||
"Reviewed",
|
||||
);
|
||||
await command(page, "Disable example buttons");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Run review", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(page, testInfo, "10-disabled-buttons");
|
||||
await command(page, "Enable example buttons");
|
||||
await command(page, "Use icon-only header");
|
||||
await capture(page, testInfo, "11-icon-only-header");
|
||||
await command(page, "Hide review button");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await command(page, "Show review button");
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toContainText(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
await workspace.client.renameProject(
|
||||
workspace.projectId,
|
||||
"Plugin buttons with a long project name",
|
||||
);
|
||||
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(
|
||||
"Plugin buttons with a long project name",
|
||||
);
|
||||
await showcase.setTitle("Button showcase with a long workspace title");
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveText(
|
||||
"Button showcase with a long workspace title",
|
||||
);
|
||||
await expectHeaderTextTruncated(page);
|
||||
await capture(page, testInfo, "12-compact-buttons");
|
||||
await openCompactOverflowWithHeaderChrome(page, testInfo);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"13-compact-header-overflow",
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Header checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"14-compact-header-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"15-compact-header-content",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await showcase.setTitle("Compact checks");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"15b-compact-header-direct-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
await showcase.setTitle("Button showcase");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"16-compact-composer-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await press(page, "Composer checks");
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"17-compact-composer-menu",
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
}),
|
||||
hideAndUnloadOpenButtons: () =>
|
||||
test.step("hiding an open button and unloading a plugin remove its surfaces", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await press(page, "Composer checks");
|
||||
await showcase.setTitle("Hide composer menu");
|
||||
await expect(page.getByRole("menuitem", { name: "Run checks", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await showcase.setTitle("Button showcase");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
await capture(page, testInfo, "18-dark-buttons");
|
||||
await press(page, "Header status");
|
||||
await expectDetails(page);
|
||||
await capture(
|
||||
page,
|
||||
testInfo,
|
||||
"19-dark-popover",
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await showcase.disable();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Header status", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Run review", exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByText("All systems operational", { exact: true })).toHaveCount(0);
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
await showcase.cleanup();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { openModelPicker } from "./agent-profiles";
|
||||
import { openAgentRoute } from "./mock-agent";
|
||||
import { connectNewWorkspaceDaemonClient, openGlobalNewWorkspaceComposer } from "./new-workspace";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { openSettingsHostSection } from "./settings";
|
||||
|
||||
const MODEL_LABEL = "Select model (Example 1)";
|
||||
const WIDE = { width: 1400, height: 950 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
|
||||
async function readIconPaths(page: Page, pluginDirectory: string): Promise<string[]> {
|
||||
const svg = await readFile(path.join(pluginDirectory, "icon.svg"), "utf8");
|
||||
return page.evaluate(
|
||||
(source) =>
|
||||
Array.from(
|
||||
new DOMParser().parseFromString(source, "image/svg+xml").querySelectorAll("path"),
|
||||
(element) => element.getAttribute("d") ?? "",
|
||||
),
|
||||
svg,
|
||||
);
|
||||
}
|
||||
|
||||
function readIconDrawings(icons: SVGElement[]): string[][] {
|
||||
return icons.map((icon) =>
|
||||
Array.from(icon.querySelectorAll("path"), (element) => element.getAttribute("d") ?? ""),
|
||||
);
|
||||
}
|
||||
|
||||
async function expectProviderIcon(surface: Locator, paths: string[]): Promise<void> {
|
||||
await expect(surface).toBeVisible();
|
||||
// Provider icons are decorative SVGs without accessible names. Compare the
|
||||
// plugin asset's drawing, allowing surface-specific size and theme colours.
|
||||
await expect
|
||||
.poll(() => surface.locator("svg").evaluateAll(readIconDrawings))
|
||||
.toContainEqual(paths);
|
||||
}
|
||||
|
||||
async function selectPluginModel(page: Page): Promise<void> {
|
||||
await openModelPicker(page);
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await page.getByText("Direct provider example", { exact: true }).click();
|
||||
await page.getByText("Example 1", { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: MODEL_LABEL, exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const screenshot = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach(name, { path: screenshot, contentType: "image/png" });
|
||||
}
|
||||
|
||||
interface ProviderIconJourney {
|
||||
page: Page;
|
||||
testInfo: TestInfo;
|
||||
iconPaths: string[];
|
||||
workspace: SeededWorkspace;
|
||||
}
|
||||
|
||||
export const test = base.extend<{ providerIcons: ProviderIconJourney }>({
|
||||
providerIcons: async ({ page }, provide, testInfo) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
try {
|
||||
const previous = await client.getDaemonConfig();
|
||||
const plugin = await copyPluginExample("provider-direct");
|
||||
try {
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(plugin.directory);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "plugin-provider-icons-" });
|
||||
try {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
const iconPaths = await readIconPaths(page, plugin.directory);
|
||||
await provide({ page, testInfo, iconPaths, workspace });
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.removePlugin("provider-direct-example");
|
||||
} finally {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await plugin.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export async function verifyProviderSettings({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("provider settings render the registered icon", async () => {
|
||||
await openSettings(page);
|
||||
await openSettingsHostSection(page, getServerId(), "providers");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: "Direct provider example provider details", exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "provider-settings");
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyNewWorkspaceModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("new workspace keeps the picker icon on its selected model button", async () => {
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectPluginModel(page);
|
||||
await openModelPicker(page);
|
||||
await expectProviderIcon(page.getByTestId("model-row-direct-example-example-1"), iconPaths);
|
||||
await capture(page, testInfo, "new-workspace-picker");
|
||||
await page.keyboard.press("Escape");
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "new-workspace-selected-model");
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyCompactModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("compact composer preserves the same provider icon", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "compact-selected-model");
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyExistingAgentModelIcon({
|
||||
page,
|
||||
iconPaths,
|
||||
testInfo,
|
||||
workspace,
|
||||
}: ProviderIconJourney): Promise<void> {
|
||||
await test.step("existing agent composer uses the same icon", async () => {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "direct-example",
|
||||
model: "example-1",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: "Plugin icon agent",
|
||||
});
|
||||
await page.setViewportSize(WIDE);
|
||||
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
|
||||
await expectProviderIcon(
|
||||
page.getByRole("button", { name: MODEL_LABEL, exact: true }),
|
||||
iconPaths,
|
||||
);
|
||||
await capture(page, testInfo, "agent-selected-model");
|
||||
});
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export interface SeedDaemonClient {
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
clearWorkspaceAttention(workspaceId: string): Promise<void>;
|
||||
setWorkspaceLabel(input: {
|
||||
workspaceId: string;
|
||||
label: { name: string; color: "red" };
|
||||
@@ -159,6 +160,7 @@ export interface SeedDaemonClient {
|
||||
features?: {
|
||||
projectAdd?: boolean;
|
||||
workspaceRecovery?: boolean;
|
||||
workspaceMarkUnread?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
import { QuittingOverlay } from "@/components/quitting-overlay";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { ChangelogHost } from "@/changelog";
|
||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
@@ -619,6 +620,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
<AppDiagnosticHost />
|
||||
<ChangelogHost />
|
||||
<QuittingOverlay />
|
||||
</AppearanceStyleBoundary>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useChangelogStore } from "./internal/changelog-store";
|
||||
import { ChangelogSheet } from "./internal/changelog-sheet";
|
||||
|
||||
/** Mounted once by the app shell so any surface can call `openChangelog()`. */
|
||||
export function ChangelogHost() {
|
||||
const visible = useChangelogStore((state) => state.visible);
|
||||
const close = useChangelogStore((state) => state.close);
|
||||
|
||||
return <ChangelogSheet visible={visible} onClose={close} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* What's new: the release notes, read from the repository's CHANGELOG.md and
|
||||
* rendered in the app instead of sending the reader to a browser.
|
||||
*
|
||||
* Two things leave this module — the host the app shell mounts, and the command
|
||||
* every entry point calls. Where the document comes from, how it is parsed, and
|
||||
* how much of it renders at once stay inside; import from `@/changelog`, never
|
||||
* a path within it.
|
||||
*/
|
||||
export { ChangelogHost } from "./changelog-host";
|
||||
export { openChangelog } from "./open-changelog";
|
||||
@@ -0,0 +1,241 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ExternalLink, Gift } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useChangelog, type ChangelogState } from "./changelog-source";
|
||||
import { useRevealedReleases } from "./use-revealed-releases";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
const WEBSITE_CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const sheetLeadingIcon = <ThemedGift size={ICON_SIZE.md} uniProps={mutedColorMapping} />;
|
||||
const websiteButtonStyle = (state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
iconButtonChromeStyle({ size: "large", state });
|
||||
|
||||
interface ChangelogSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangelogSheet({ visible, onClose }: ChangelogSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state, reload } = useChangelog(visible);
|
||||
const { count, showMore } = useRevealedReleases(visible && state.status === "ready");
|
||||
|
||||
const handleOpenWebsite = useCallback(() => {
|
||||
void openExternalUrl(WEBSITE_CHANGELOG_URL);
|
||||
}, []);
|
||||
|
||||
const header: SheetHeader = useMemo(
|
||||
() => ({
|
||||
title: t("changelog.title"),
|
||||
leading: sheetLeadingIcon,
|
||||
actions: (
|
||||
<Pressable
|
||||
onPress={handleOpenWebsite}
|
||||
hitSlop={8}
|
||||
style={websiteButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("changelog.openWebsite")}
|
||||
testID="changelog-open-website"
|
||||
>
|
||||
<ThemedExternalLink
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
uniProps={mutedColorMapping}
|
||||
/>
|
||||
</Pressable>
|
||||
),
|
||||
}),
|
||||
[handleOpenWebsite, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
desktopMaxWidth={620}
|
||||
desktopHeight="85%"
|
||||
testID="changelog-sheet"
|
||||
>
|
||||
<ChangelogBody state={state} shownReleases={count} onShowMore={showMore} onRetry={reload} />
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
const SNAP_POINTS = ["85%", "95%"];
|
||||
|
||||
interface ChangelogBodyProps {
|
||||
state: ChangelogState;
|
||||
shownReleases: number;
|
||||
onShowMore: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function ChangelogBody({ state, shownReleases, onShowMore, onRetry }: ChangelogBodyProps) {
|
||||
const { t } = useTranslation();
|
||||
const appVersion = useMemo(() => resolveAppVersion()?.replace(/^v/i, "") ?? null, []);
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<ThemedLoadingSpinner size="large" uniProps={mutedColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Alert
|
||||
variant="error"
|
||||
title={t("changelog.error.title")}
|
||||
description={t("changelog.error.description")}
|
||||
testID="changelog-error"
|
||||
>
|
||||
<Button variant="outline" size="sm" onPress={onRetry} testID="changelog-retry">
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</Alert>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const visibleReleases = state.releases.slice(0, shownReleases);
|
||||
|
||||
return (
|
||||
<View style={styles.releaseList}>
|
||||
{visibleReleases.map((release) => (
|
||||
<ReleaseView
|
||||
key={`${release.version}:${release.date}`}
|
||||
release={release}
|
||||
isCurrent={release.version === appVersion}
|
||||
/>
|
||||
))}
|
||||
{state.releases.length > visibleReleases.length ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={onShowMore}
|
||||
style={styles.showMore}
|
||||
testID="changelog-show-more"
|
||||
>
|
||||
{t("changelog.showMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReleaseViewProps {
|
||||
release: ChangelogRelease;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const ReleaseView = memo(function ReleaseView({ release, isCurrent }: ReleaseViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const date = formatChangelogDate(release.date);
|
||||
|
||||
return (
|
||||
<View style={styles.release} testID={`changelog-release-${release.version}`}>
|
||||
<View style={styles.releaseHeading}>
|
||||
<Text style={styles.version}>{release.version}</Text>
|
||||
{isCurrent ? <StatusBadge label={t("changelog.installed")} /> : null}
|
||||
<View style={styles.headingSpacer} />
|
||||
{date ? <Text style={styles.date}>{date}</Text> : null}
|
||||
</View>
|
||||
{keyChangelogSections(release.sections).map(({ key, section }) => (
|
||||
<View key={key} style={styles.section}>
|
||||
{section.title ? <Text style={styles.sectionTitle}>{section.title}</Text> : null}
|
||||
{section.body ? <MarkdownRenderer text={section.body} compact /> : null}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
/** A release can repeat a section title; the occurrence keeps the key stable. */
|
||||
function keyChangelogSections(
|
||||
sections: readonly ChangelogSection[],
|
||||
): { key: string; section: ChangelogSection }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return sections.map((section) => {
|
||||
const title = section.title ?? "";
|
||||
const occurrence = seen.get(title) ?? 0;
|
||||
seen.set(title, occurrence + 1);
|
||||
return { key: `${title}:${occurrence}`, section };
|
||||
});
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
minHeight: 160,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
releaseList: {
|
||||
gap: theme.spacing[8],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
release: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
releaseHeading: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[2],
|
||||
borderBottomWidth: theme.borderWidth[1],
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
headingSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
version: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
date: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
showMore: {
|
||||
alignSelf: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { parseChangelog, type ChangelogRelease } from "./parse-changelog";
|
||||
|
||||
const CHANGELOG_URL = "https://raw.githubusercontent.com/getpaseo/paseo/main/CHANGELOG.md";
|
||||
|
||||
export type ChangelogState =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; releases: ChangelogRelease[] }
|
||||
| { status: "error" };
|
||||
|
||||
// Survives close/reopen so the second look paints without a spinner. The raw
|
||||
// text is kept alongside the releases so an unchanged revalidation can be
|
||||
// dropped: handing back an equal-but-new array would re-render every release.
|
||||
let cached: { markdown: string; releases: ChangelogRelease[] } | null = null;
|
||||
|
||||
export interface Changelog {
|
||||
state: ChangelogState;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the changelog from the repository the app was built from.
|
||||
*
|
||||
* The daemon is not involved: the changelog describes the app, a phone reaching
|
||||
* a relay already has internet, and going through a host would make the notes
|
||||
* depend on which host happens to be connected.
|
||||
*
|
||||
* Every open refetches, because the whole point of opening it is a release that
|
||||
* shipped after this app started. A previous result stays on screen while that
|
||||
* happens, and survives a failed revalidation.
|
||||
*/
|
||||
export function useChangelog(enabled: boolean): Changelog {
|
||||
const [state, setState] = useState<ChangelogState>(readCache);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
setState(readCache());
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(CHANGELOG_URL, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`Changelog request failed: ${response.status}`);
|
||||
const markdown = await response.text();
|
||||
if (cached?.markdown === markdown) return;
|
||||
const releases = parseChangelog(markdown);
|
||||
if (releases.length === 0) throw new Error("Changelog has no releases");
|
||||
cached = { markdown, releases };
|
||||
setState({ status: "ready", releases });
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
if (cached) return;
|
||||
setState({ status: "error" });
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [enabled, attempt]);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
cached = null;
|
||||
setAttempt((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
return { state, reload };
|
||||
}
|
||||
|
||||
function readCache(): ChangelogState {
|
||||
return cached ? { status: "ready", releases: cached.releases } : { status: "loading" };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface ChangelogVisibility {
|
||||
visible: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useChangelogStore = create<ChangelogVisibility>((set) => ({
|
||||
visible: false,
|
||||
open: () => set({ visible: true }),
|
||||
close: () => set({ visible: false }),
|
||||
}));
|
||||
@@ -0,0 +1,301 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatChangelogDate,
|
||||
parseChangelog,
|
||||
type ChangelogRelease,
|
||||
type ChangelogSection,
|
||||
} from "./parse-changelog";
|
||||
|
||||
function bodies(markdown: string, releaseIndex = 0): string[] {
|
||||
return parseChangelog(markdown)[releaseIndex].sections.map(sectionBody);
|
||||
}
|
||||
|
||||
function sectionBody(section: ChangelogSection): string {
|
||||
return section.body;
|
||||
}
|
||||
|
||||
function sectionTitle(section: ChangelogSection): string | null {
|
||||
return section.title;
|
||||
}
|
||||
|
||||
function releaseDate(release: ChangelogRelease): string {
|
||||
return release.date;
|
||||
}
|
||||
|
||||
function authoredLines(release: ChangelogRelease): string[] {
|
||||
return release.sections.flatMap(sectionLines);
|
||||
}
|
||||
|
||||
function sectionLines(section: ChangelogSection): string[] {
|
||||
return [...(section.title ? [`### ${section.title}`] : []), ...section.body.split("\n")];
|
||||
}
|
||||
|
||||
function isNotBlank(line: string): boolean {
|
||||
return line.trim().length > 0;
|
||||
}
|
||||
|
||||
describe("parseChangelog", () => {
|
||||
it("reads the shape the changelog is authored in today", () => {
|
||||
const releases = parseChangelog(
|
||||
[
|
||||
"# Changelog",
|
||||
"",
|
||||
"## 0.8.0-beta.1 - 2026-09-08",
|
||||
"",
|
||||
"This release extends what plugins can do.",
|
||||
"",
|
||||
"### Plugins",
|
||||
"",
|
||||
"- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed the mobile terminal keyboard closing",
|
||||
"",
|
||||
"## 0.7.2 - 2026-09-02",
|
||||
"",
|
||||
"### Fixed",
|
||||
"",
|
||||
"- Fixed a thing",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(releases).toEqual([
|
||||
{
|
||||
version: "0.8.0-beta.1",
|
||||
date: "2026-09-08",
|
||||
sections: [
|
||||
{ title: null, body: "This release extends what plugins can do." },
|
||||
{
|
||||
title: "Plugins",
|
||||
body: "- Added provider contributions ([#4314](https://example.com/4314))",
|
||||
},
|
||||
{ title: "Fixed", body: "- Fixed the mobile terminal keyboard closing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.7.2",
|
||||
date: "2026-09-02",
|
||||
sections: [{ title: "Fixed", body: "- Fixed a thing" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops everything above the first release heading", () => {
|
||||
const releases = parseChangelog(
|
||||
"# Changelog\n\nAll notable changes.\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "- One" }]);
|
||||
});
|
||||
|
||||
it("returns nothing for a document with no releases", () => {
|
||||
expect(parseChangelog("")).toEqual([]);
|
||||
expect(parseChangelog("# Changelog\n\nNothing yet.\n")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("release headings", () => {
|
||||
const headings: [string, { version: string; date: string }][] = [
|
||||
["## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3", { version: "1.2.3", date: "" }],
|
||||
["## [1.2.3] - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## v1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 — 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 – 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 (2026-01-01)", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - January 1, 2026", { version: "1.2.3", date: "January 1, 2026" }],
|
||||
[
|
||||
"## [1.2.3](https://example.com/v1.2.3) - 2026-01-01",
|
||||
{ version: "1.2.3", date: "2026-01-01" },
|
||||
],
|
||||
["## Unreleased", { version: "Unreleased", date: "" }],
|
||||
["## [Unreleased]", { version: "Unreleased", date: "" }],
|
||||
["## 1.2.3 - 2026-01-01 ", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 1.2.3 - 2026-01-01 ##", { version: "1.2.3", date: "2026-01-01" }],
|
||||
[" ## 1.2.3 - 2026-01-01", { version: "1.2.3", date: "2026-01-01" }],
|
||||
["## 0.8.0-beta.1 - 2026-09-08", { version: "0.8.0-beta.1", date: "2026-09-08" }],
|
||||
];
|
||||
|
||||
for (const [heading, expected] of headings) {
|
||||
it(`reads ${JSON.stringify(heading)}`, () => {
|
||||
const [release] = parseChangelog(`${heading}\n\n- Entry\n`);
|
||||
expect({ version: release.version, date: release.date }).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps both entries when a version appears twice", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-02\n\n- Two\n\n## 1.0.0 - 2026-01-01\n\n- One\n",
|
||||
);
|
||||
expect(releases.map(releaseDate)).toEqual(["2026-01-02", "2026-01-01"]);
|
||||
});
|
||||
|
||||
it("does not treat a deeper heading as a release", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n#### Deep\n\n- One\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "#### Deep\n\n- One" }]);
|
||||
});
|
||||
|
||||
it("does not treat an unspaced hash run as a heading", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n##notaheading\n");
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections[0].body).toBe("##notaheading");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sections", () => {
|
||||
it("names sections from the document instead of a fixed list", () => {
|
||||
const releases = parseChangelog(
|
||||
"## 1.0.0 - 2026-01-01\n\n### Security\n\n- One\n\n### Deprecated\n\n- Two\n",
|
||||
);
|
||||
expect(releases[0].sections.map(sectionTitle)).toEqual(["Security", "Deprecated"]);
|
||||
});
|
||||
|
||||
it("keeps a section that has a heading but no body", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n### Added\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([
|
||||
{ title: "Fixed", body: "" },
|
||||
{ title: "Added", body: "- One" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops an empty lead section", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\n### Fixed\n\n- One\n");
|
||||
expect(releases[0].sections).toEqual([{ title: "Fixed", body: "- One" }]);
|
||||
});
|
||||
|
||||
it("keeps a release that is only prose", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\n\nJust a note.\n");
|
||||
expect(releases[0].sections).toEqual([{ title: null, body: "Just a note." }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("block kinds it does not interpret", () => {
|
||||
it("passes a block quote, callout, table and embed through untouched", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Notes",
|
||||
"",
|
||||
"> [!WARNING]",
|
||||
"> This release drops macOS 12.",
|
||||
"",
|
||||
"| Platform | Status |",
|
||||
"| --- | --- |",
|
||||
"| macOS | Shipped |",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
'<video src="https://example.com/demo.mp4" controls></video>',
|
||||
"",
|
||||
'<iframe src="https://example.com/embed"></iframe>',
|
||||
"",
|
||||
"<details><summary>More</summary>Hidden</details>",
|
||||
].join("\n");
|
||||
|
||||
expect(bodies(source)).toEqual([source.split("\n").slice(4).join("\n")]);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fenced code block", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"### Added",
|
||||
"",
|
||||
"```md",
|
||||
"## 9.9.9 - 2099-01-01",
|
||||
"### Fake section",
|
||||
"```",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
const releases = parseChangelog(source);
|
||||
expect(releases).toHaveLength(1);
|
||||
expect(releases[0].sections).toEqual([
|
||||
{
|
||||
title: "Added",
|
||||
body: "```md\n## 9.9.9 - 2099-01-01\n### Fake section\n```\n\n- Real entry",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks tilde fences and longer fence runs", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"~~~~",
|
||||
"## 9.9.9",
|
||||
"```",
|
||||
"~~~~",
|
||||
"",
|
||||
"- Real entry",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not split on a heading inside a fence nested in a list item", () => {
|
||||
const source = [
|
||||
"## 1.0.0 - 2026-01-01",
|
||||
"",
|
||||
"- Entry with a sample",
|
||||
"",
|
||||
" ```",
|
||||
" ## 9.9.9",
|
||||
" ```",
|
||||
].join("\n");
|
||||
|
||||
expect(parseChangelog(source)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("reads CRLF documents", () => {
|
||||
const releases = parseChangelog("## 1.0.0 - 2026-01-01\r\n\r\n### Fixed\r\n\r\n- One\r\n");
|
||||
expect(releases).toEqual([
|
||||
{ version: "1.0.0", date: "2026-01-01", sections: [{ title: "Fixed", body: "- One" }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatChangelogDate", () => {
|
||||
it("localizes an ISO date without shifting it across a timezone", () => {
|
||||
expect(formatChangelogDate("2026-09-08")).toBe("September 8, 2026");
|
||||
});
|
||||
|
||||
it("shows anything else as authored", () => {
|
||||
expect(formatChangelogDate("September 8, 2026")).toBe("September 8, 2026");
|
||||
expect(formatChangelogDate("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the repository's own CHANGELOG.md", () => {
|
||||
const markdown = readFileSync(
|
||||
fileURLToPath(new URL("../../../../../CHANGELOG.md", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
const releases = parseChangelog(markdown);
|
||||
|
||||
it("parses every release heading", () => {
|
||||
expect(releases.length).toBeGreaterThan(50);
|
||||
for (const release of releases) {
|
||||
expect(release.version).toMatch(/^\d+\.\d+\.\d+/);
|
||||
expect(release.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(release.sections.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps every authored line inside a release", () => {
|
||||
const documentLines = markdown.split("\n");
|
||||
const fromFirstRelease = documentLines.slice(
|
||||
documentLines.findIndex((line) => line.startsWith("## ")),
|
||||
);
|
||||
|
||||
expect(releases.flatMap(authoredLines).filter(isNotBlank)).toEqual(
|
||||
fromFirstRelease.filter(isNotBlank).filter((line) => !line.startsWith("## ")),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Structural parser for a CHANGELOG.md document.
|
||||
*
|
||||
* The document is authored in the repository, fetched at runtime, and rewritten
|
||||
* on every release, so this parser commits to exactly two facts: a `##` heading
|
||||
* starts a release and a `###` heading starts a section inside it. Everything
|
||||
* else — prose, callouts, block quotes, fenced code, images, video and embed
|
||||
* HTML, tables, nested lists — is carried through verbatim and handed to the
|
||||
* Markdown renderer. A new block kind in the changelog therefore needs no
|
||||
* change here.
|
||||
*
|
||||
* Section titles are read, never matched: renaming "Improved" or adding a
|
||||
* section is a content change, not a code change.
|
||||
*
|
||||
* The one way a line scan can be fooled is a fenced block whose contents look
|
||||
* like a heading, so fences are tracked.
|
||||
*/
|
||||
|
||||
export interface ChangelogSection {
|
||||
/** The `###` heading text, or null for content that precedes the first one. */
|
||||
title: string | null;
|
||||
/** Verbatim markdown under the heading. */
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ChangelogRelease {
|
||||
/** Version as authored, stripped of link, bracket and `v` decoration. */
|
||||
version: string;
|
||||
/** The heading tail after the version. Empty when the heading carried none. */
|
||||
date: string;
|
||||
/** Sections in document order; the untitled lead section comes first. */
|
||||
sections: ChangelogSection[];
|
||||
}
|
||||
|
||||
// `##` / `###` must be followed by whitespace, which is what keeps each pattern
|
||||
// from matching one level deeper.
|
||||
const RELEASE_HEADING = /^ {0,3}##(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const SECTION_HEADING = /^ {0,3}###(?:[ \t]+(.*?))?[ \t]*$/;
|
||||
const FENCE_OPEN = /^\s*(`{3,}|~{3,})/;
|
||||
const ATX_CLOSING = /[ \t]+#+[ \t]*$/;
|
||||
const MARKDOWN_LINK = /\[([^\]]*)\]\([^)]*\)/g;
|
||||
const DASH_SEPARATED_DATE = /^(.*?)\s+[-–—]\s+(.+)$/;
|
||||
const PARENTHESIZED_DATE = /^(.*?)\s*\((.+)\)$/;
|
||||
const BRACKETED_VERSION = /^\[(.*)\]$/;
|
||||
const LEADING_V = /^v(?=\d)/i;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
interface SectionDraft {
|
||||
title: string | null;
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
interface ReleaseDraft {
|
||||
version: string;
|
||||
date: string;
|
||||
sections: SectionDraft[];
|
||||
}
|
||||
|
||||
export function parseChangelog(markdown: string): ChangelogRelease[] {
|
||||
const releases: ReleaseDraft[] = [];
|
||||
let fence: string | null = null;
|
||||
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const fenceEdge = line.match(FENCE_OPEN)?.[1];
|
||||
if (fenceEdge) {
|
||||
if (!fence) {
|
||||
fence = fenceEdge;
|
||||
} else if (fenceEdge[0] === fence[0] && fenceEdge.length >= fence.length) {
|
||||
fence = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fence) {
|
||||
const releaseHeading = line.match(RELEASE_HEADING);
|
||||
if (releaseHeading) {
|
||||
releases.push({
|
||||
...splitReleaseHeading(releaseHeading[1] ?? ""),
|
||||
sections: [{ title: null, lines: [] }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionHeading = line.match(SECTION_HEADING);
|
||||
if (sectionHeading && releases.length > 0) {
|
||||
releases[releases.length - 1].sections.push({
|
||||
title: (sectionHeading[1] ?? "").replace(ATX_CLOSING, "").trim() || null,
|
||||
lines: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const release = releases.at(-1);
|
||||
if (release) release.sections[release.sections.length - 1].lines.push(line);
|
||||
}
|
||||
|
||||
return releases.map(materializeRelease);
|
||||
}
|
||||
|
||||
function materializeRelease(draft: ReleaseDraft): ChangelogRelease {
|
||||
const sections = draft.sections
|
||||
.map((section) => ({ title: section.title, body: trimBlankLines(section.lines) }))
|
||||
// A heading with an empty body still names something; an empty lead section
|
||||
// is just the gap between the release heading and the first section.
|
||||
.filter((section) => section.title !== null || section.body.length > 0);
|
||||
return { version: draft.version, date: draft.date, sections };
|
||||
}
|
||||
|
||||
function trimBlankLines(lines: string[]): string {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && lines[start].trim().length === 0) start += 1;
|
||||
while (end > start && lines[end - 1].trim().length === 0) end -= 1;
|
||||
return lines.slice(start, end).join("\n");
|
||||
}
|
||||
|
||||
function splitReleaseHeading(heading: string): { version: string; date: string } {
|
||||
const plain = heading.replace(ATX_CLOSING, "").replace(MARKDOWN_LINK, "$1").trim();
|
||||
|
||||
const dashed = plain.match(DASH_SEPARATED_DATE);
|
||||
if (dashed) return { version: normalizeVersion(dashed[1]), date: dashed[2].trim() };
|
||||
|
||||
const parenthesized = plain.match(PARENTHESIZED_DATE);
|
||||
if (parenthesized) {
|
||||
return { version: normalizeVersion(parenthesized[1]), date: parenthesized[2].trim() };
|
||||
}
|
||||
|
||||
return { version: normalizeVersion(plain), date: "" };
|
||||
}
|
||||
|
||||
function normalizeVersion(raw: string): string {
|
||||
const unbracketed = raw.trim().replace(BRACKETED_VERSION, "$1").trim();
|
||||
return unbracketed.replace(LEADING_V, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO dates become the reader's locale; anything else the author wrote is shown
|
||||
* as authored rather than guessed at.
|
||||
*/
|
||||
export function formatChangelogDate(date: string): string {
|
||||
if (!ISO_DATE.test(date)) return date;
|
||||
const [year, month, day] = date.split("-").map(Number);
|
||||
const value = new Date(Date.UTC(year, month - 1, day));
|
||||
if (Number.isNaN(value.getTime())) return date;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(value);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { startTransition, useCallback, useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* How much of the changelog is on screen.
|
||||
*
|
||||
* The latest release renders as soon as there is one, because that is what
|
||||
* nearly every open is asking for. The rest of the first page follows
|
||||
* immediately, and every Show more after it, as transitions: React then slices
|
||||
* that work around the opening animation instead of committing hundreds of list
|
||||
* items in one task. The whole document is well over a thousand of them.
|
||||
*
|
||||
* The input is "releases are on screen", not "the sheet is open". A cold open
|
||||
* spends its first moment on the loading state, and stepping the count up
|
||||
* during it would land the whole first page in the one commit that follows —
|
||||
* the exact commit this exists to split.
|
||||
*/
|
||||
const LATEST_RELEASE_ONLY = 1;
|
||||
const FIRST_PAGE = 5;
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export interface RevealedReleases {
|
||||
count: number;
|
||||
showMore: () => void;
|
||||
}
|
||||
|
||||
export function useRevealedReleases(hasReleases: boolean): RevealedReleases {
|
||||
const [count, setCount] = useState(LATEST_RELEASE_ONLY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasReleases) {
|
||||
setCount(LATEST_RELEASE_ONLY);
|
||||
return;
|
||||
}
|
||||
startTransition(() => setCount(FIRST_PAGE));
|
||||
}, [hasReleases]);
|
||||
|
||||
const showMore = useCallback(() => {
|
||||
startTransition(() => setCount((current) => current + PAGE_SIZE));
|
||||
}, []);
|
||||
|
||||
return { count, showMore };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useChangelogStore } from "./internal/changelog-store";
|
||||
|
||||
/** Opens the What's new sheet from anywhere, including outside React. */
|
||||
export function openChangelog(): void {
|
||||
useChangelogStore.getState().open();
|
||||
}
|
||||
@@ -171,6 +171,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
desktopScrollContainer: {
|
||||
// Grows only when the card has an explicit `desktopHeight`; a content-sized
|
||||
// card has nothing to grow into. Without it a fixed-height card with short
|
||||
// content leaves the footer stranded in the middle.
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
|
||||
@@ -255,6 +255,7 @@ export function CombinedModelSelector({
|
||||
<View style={toolbar?.glyphSize === 20 ? styles.toolbarGlyph20 : styles.toolbarGlyph16}>
|
||||
<ModelProviderGlyph
|
||||
provider={selectedProvider}
|
||||
serverId={serverId}
|
||||
size={toolbar?.glyphSize ?? ICON_SIZE.md}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -219,7 +219,7 @@ export function ModelProviderGlyph({
|
||||
tone = "muted",
|
||||
}: {
|
||||
provider: string;
|
||||
serverId?: string | null;
|
||||
serverId: string | null;
|
||||
size: number;
|
||||
tone?: ProviderGlyphTone;
|
||||
}) {
|
||||
|
||||
@@ -124,7 +124,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
@@ -284,6 +284,7 @@ interface WorkspaceRowInnerProps {
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
@@ -612,6 +613,7 @@ function WorkspaceRowRightGroup({
|
||||
archiveShortcutKeys,
|
||||
onArchive,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
@@ -631,6 +633,7 @@ function WorkspaceRowRightGroup({
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
onArchive?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -682,6 +685,7 @@ function WorkspaceRowRightGroup({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -1065,6 +1069,8 @@ function WorkspaceRowInner({
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
@@ -1135,6 +1141,8 @@ function WorkspaceRowInner({
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -1185,6 +1193,8 @@ function WorkspaceRowInner({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
@@ -1286,15 +1296,21 @@ function WorkspaceRowWithMenu({
|
||||
const onTogglePin = canPin ? handleTogglePin : undefined;
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
||||
useWorkspaceReadState({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
const handleMarkAsUnread = useCallback(() => {
|
||||
void markUnread().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
||||
});
|
||||
}, [markUnread, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
@@ -1332,6 +1348,7 @@ function WorkspaceRowWithMenu({
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
|
||||
@@ -24,11 +24,11 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { openChangelog } from "@/changelog";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
const ThemedActivity = withUnistyles(Activity);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
@@ -97,10 +97,6 @@ export function SidebarHelpMenu() {
|
||||
void openExternalUrl(GITHUB_ISSUE_URL);
|
||||
}, []);
|
||||
|
||||
const openChangelog = useCallback(() => {
|
||||
void openExternalUrl(CHANGELOG_URL);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
|
||||
@@ -48,7 +48,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
@@ -641,15 +641,21 @@ function StatusWorkspaceRowWithMenu({
|
||||
const onTogglePin = canPin ? handleTogglePin : undefined;
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
||||
useWorkspaceReadState({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
const handleMarkAsUnread = useCallback(() => {
|
||||
void markUnread().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
||||
});
|
||||
}, [markUnread, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
@@ -682,6 +688,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
@@ -719,6 +726,7 @@ interface StatusWorkspaceRowInnerProps {
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
@@ -765,6 +773,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
@@ -860,6 +869,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -907,6 +917,7 @@ function StatusWorkspaceRowInnerContent({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -937,6 +948,7 @@ function StatusWorkspaceActionSlot({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -956,6 +968,7 @@ function StatusWorkspaceActionSlot({
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onArchive?: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
@@ -983,6 +996,7 @@ function StatusWorkspaceActionSlot({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Archive,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
Copy,
|
||||
MoreVertical,
|
||||
@@ -52,6 +53,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedCircle = withUnistyles(Circle);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedPin = withUnistyles(Pin);
|
||||
@@ -63,6 +65,7 @@ const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColor
|
||||
const markAsReadLeadingIcon = (
|
||||
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
const markAsUnreadLeadingIcon = <ThemedCircle size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const pinLeadingIcon = <ThemedPin size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const unpinLeadingIcon = <ThemedPinOff size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
@@ -85,6 +88,7 @@ export interface SidebarWorkspaceMenuProps {
|
||||
onCopyBranchName?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onArchive: () => void;
|
||||
archiveLabel?: string;
|
||||
archiveStatus?: "idle" | "pending" | "success";
|
||||
@@ -132,6 +136,7 @@ function SidebarWorkspaceMenuItems({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -193,6 +198,16 @@ function SidebarWorkspaceMenuItems({
|
||||
Mark as read
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onMarkAsUnread ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
testID={`sidebar-workspace-menu-mark-as-unread-${workspaceKey}`}
|
||||
leading={markAsUnreadLeadingIcon}
|
||||
onSelect={onMarkAsUnread}
|
||||
>
|
||||
Mark as unread
|
||||
</WorkspaceMenuItem>
|
||||
) : null}
|
||||
{onTogglePin ? (
|
||||
<WorkspaceMenuItem
|
||||
surface={surface}
|
||||
@@ -243,6 +258,7 @@ export function SidebarWorkspaceMenu({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -288,6 +304,7 @@ export function SidebarWorkspaceMenu({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -320,6 +337,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
onCopyBranchName,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onArchive,
|
||||
archiveLabel,
|
||||
archiveStatus,
|
||||
@@ -399,6 +417,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
|
||||
import { useWorkspaceReadState } from "@/hooks/use-workspace-read-state";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -123,15 +123,21 @@ export function SidebarWorkspaceRow({
|
||||
}, []);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const { hasClearableAttention, canMarkUnread, clearAttention, markUnread } =
|
||||
useWorkspaceReadState({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
void clearAttention().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
|
||||
});
|
||||
}, [clearAttention, toast]);
|
||||
const handleMarkAsUnread = useCallback(() => {
|
||||
void markUnread().catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as unread");
|
||||
});
|
||||
}, [markUnread, toast]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `workspace-archive-${workspace.workspaceKey}`,
|
||||
@@ -166,6 +172,7 @@ export function SidebarWorkspaceRow({
|
||||
onCopyPath={handleCopyPath}
|
||||
onRename={handleOpenRename}
|
||||
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
|
||||
onMarkAsUnread={canMarkUnread ? handleMarkAsUnread : undefined}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
<WorkspaceRenameModal
|
||||
@@ -198,6 +205,7 @@ interface WorkspaceRowBodyProps {
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
@@ -221,6 +229,7 @@ function WorkspaceRowBody({
|
||||
onCopyPath,
|
||||
onRename,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -293,6 +302,7 @@ function WorkspaceRowBody({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
@@ -340,6 +350,7 @@ function WorkspaceRowBody({
|
||||
onCopyPath={onCopyPath}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
/>
|
||||
</SidebarWorkspaceRowContent>
|
||||
</SidebarWorkspaceContextMenu>
|
||||
@@ -365,6 +376,7 @@ function WorkspaceRowTrailingActions({
|
||||
archiveShortcutKeys,
|
||||
onArchive,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
onRename,
|
||||
@@ -383,6 +395,7 @@ function WorkspaceRowTrailingActions({
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
onArchive?: () => void;
|
||||
onMarkAsRead?: () => void;
|
||||
onMarkAsUnread?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onRename?: () => void;
|
||||
@@ -430,6 +443,7 @@ function WorkspaceRowTrailingActions({
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
onRename={onRename}
|
||||
onMarkAsRead={onMarkAsRead}
|
||||
onMarkAsUnread={onMarkAsUnread}
|
||||
onArchive={onArchive}
|
||||
archiveLabel={archiveLabel}
|
||||
archiveStatus={archiveStatus}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload, CreateAgentRequestMessage } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
|
||||
export interface WorkspaceDraftAgentRequest {
|
||||
workspaceId: string;
|
||||
config: AgentSessionConfig;
|
||||
text: string;
|
||||
clientMessageId: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: CreateAgentRequestMessage["attachments"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the workspace draft tab and by the new-workspace screen when it finishes creation
|
||||
* after the user has already navigated away and no draft tab will ever mount.
|
||||
*/
|
||||
export async function requestWorkspaceDraftAgent(
|
||||
client: DaemonClient,
|
||||
request: WorkspaceDraftAgentRequest,
|
||||
): Promise<AgentSnapshotPayload> {
|
||||
const images = await encodeImages(request.images);
|
||||
return await client.createAgent({
|
||||
config: request.config,
|
||||
workspaceId: request.workspaceId,
|
||||
clientMessageId: request.clientMessageId,
|
||||
...(request.text ? { initialPrompt: request.text } : {}),
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
...(request.attachments && request.attachments.length > 0
|
||||
? { attachments: request.attachments }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,7 @@ import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import {
|
||||
@@ -194,15 +194,14 @@ async function submitDraftCreateRequest(input: {
|
||||
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
||||
});
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||
const result = await client.createAgent({
|
||||
const result = await requestWorkspaceDraftAgent(client, {
|
||||
config,
|
||||
workspaceId,
|
||||
...(text ? { initialPrompt: text } : {}),
|
||||
text,
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(attachmentsArray && attachmentsArray.length > 0 ? { attachments: attachmentsArray } : {}),
|
||||
...(images ? { images } : {}),
|
||||
...(attachmentsArray ? { attachments: attachmentsArray } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClient, CancelledError } from "@tanstack/react-query";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { GetProvidersSnapshotResponseMessage } from "@getpaseo/protocol/messages";
|
||||
import { compactProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import { applyProvidersSnapshotUpdate } from "./push-router";
|
||||
import { createProviderSnapshotCache } from "./provider-snapshot-cache";
|
||||
import {
|
||||
resolveProviderIconName,
|
||||
@@ -296,3 +297,41 @@ it("keeps a replacement query when the cancelled native fetch finishes late", as
|
||||
replaceProviderSnapshotIcons(serverId, []);
|
||||
}
|
||||
});
|
||||
|
||||
describe("provider snapshot publication identity", () => {
|
||||
it("keeps the hash cache's canonical objects across a catalog change and later announcements", async () => {
|
||||
const cache = createProviderSnapshotCache(createStorage());
|
||||
const queryClient = new QueryClient();
|
||||
const serverId = "canonical-provider-snapshot";
|
||||
const client = {
|
||||
async getProvidersSnapshot() {
|
||||
throw new Error("Unexpected content fetch");
|
||||
},
|
||||
};
|
||||
const changed = {
|
||||
...snapshot,
|
||||
snapshotHash: "next-hash",
|
||||
compactSnapshot: compactProviderSnapshot([{ ...entries[0]!, label: "Updated provider" }]),
|
||||
};
|
||||
try {
|
||||
for (const body of [snapshot, changed, changed]) {
|
||||
await applyProvidersSnapshotUpdate({
|
||||
serverId,
|
||||
queryClient,
|
||||
cache,
|
||||
client,
|
||||
message: { type: "providers_snapshot_update", payload: body },
|
||||
});
|
||||
const canonical = await cache.materialize(serverId, body);
|
||||
const published = queryClient.getQueryData<GetProvidersSnapshotResponseMessage["payload"]>(
|
||||
providersSnapshotQueryKey(serverId, body.cwd),
|
||||
)!;
|
||||
expect(published.entries).toBe(canonical.entries);
|
||||
expect(published.compactSnapshot).toBe(canonical.compactSnapshot);
|
||||
expect(published.entries[0]!.models).toBe(canonical.entries[0]!.models);
|
||||
}
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function fetchProvidersSnapshot(input: {
|
||||
const body = await queryClient.fetchQuery({
|
||||
queryKey: ["providerSnapshotContent", input.serverId, hash],
|
||||
gcTime: 0,
|
||||
structuralSharing: false,
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
@@ -134,6 +135,7 @@ export async function refreshAndApplyProvidersSnapshot(input: {
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
queryFn: ({ signal }) => fetchProvidersSnapshot({ ...input, signal }),
|
||||
});
|
||||
void input.queryClient.invalidateQueries({
|
||||
|
||||
@@ -146,6 +146,77 @@ function providerUpdate(generatedAt: string): ProvidersSnapshotUpdateMessage {
|
||||
}
|
||||
|
||||
describe("server data push router", () => {
|
||||
it("retains equal diff files without reconstructing changed file bodies", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
const serverId = "diff-sharing";
|
||||
const cwd = "/repo";
|
||||
const queryKey = checkoutDiffQueryKey(serverId, cwd, "uncommitted", undefined, false);
|
||||
const subscriptionId = "diff-sharing";
|
||||
queryClient.getQueryCache().build(queryClient, {
|
||||
queryKey,
|
||||
queryFn: skipToken,
|
||||
meta: checkoutDiffPushRoute({
|
||||
enabled: false,
|
||||
serverId,
|
||||
cwd,
|
||||
subscriptionId,
|
||||
compare: { mode: "uncommitted" },
|
||||
}),
|
||||
});
|
||||
const unmount = mountServerDataPushRouter({ queryClient, client: fake.client, serverId });
|
||||
type Payload = SubscribeCheckoutDiffResponseMessage["payload"];
|
||||
const files: Payload["files"] = ["a.ts", "b.ts"].map((path) => ({
|
||||
path,
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 2,
|
||||
deletions: 0,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: 2,
|
||||
lines: [
|
||||
{
|
||||
type: "add",
|
||||
content: "const answer = 42",
|
||||
tokens: [
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " answer = 42", style: null },
|
||||
],
|
||||
},
|
||||
{ type: "add", content: "answer", tokens: [{ text: "answer", style: null }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}));
|
||||
const publish = (incomingFiles: Payload["files"], requestId: string) => {
|
||||
fake.emit({
|
||||
type: "subscribe_checkout_diff_response",
|
||||
payload: { subscriptionId, cwd, files: incomingFiles, requestId, error: null },
|
||||
});
|
||||
return queryClient.getQueryData<Payload>(queryKey)!;
|
||||
};
|
||||
try {
|
||||
const first = publish(files, "first");
|
||||
const same = publish(structuredClone(files), "reopen");
|
||||
expect(same.files).toBe(first.files);
|
||||
expect(same.requestId).toBe("reopen");
|
||||
const changed = structuredClone(files);
|
||||
changed[0]!.hunks[0]!.lines[0]!.tokens![0]!.style = "variable";
|
||||
const next = publish(changed, "edit");
|
||||
expect(next.files[1]).toBe(first.files[1]);
|
||||
expect(next.files[0]).toBe(changed[0]);
|
||||
expect(next.files[0]!.hunks[0]!.lines[0]!.tokens![0]!.style).toBe("variable");
|
||||
expect(publish([], "deleted").files).toEqual([]);
|
||||
} finally {
|
||||
unmount();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("routes provider snapshot and daemon config payloads until detached", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const fake = createFakeClient();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
@@ -203,6 +204,7 @@ export async function applyProvidersSnapshotUpdate(input: {
|
||||
await input.queryClient.fetchQuery({
|
||||
queryKey,
|
||||
staleTime: 0,
|
||||
structuralSharing: false,
|
||||
retry: false,
|
||||
queryFn: ({ signal }) => {
|
||||
const incoming = announcement;
|
||||
@@ -510,6 +512,7 @@ function setCheckoutDiffPayload(input: {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
query.setOptions({ ...query.options, structuralSharing: shareCheckoutDiff });
|
||||
input.queryClient.setQueryData<CheckoutDiffCachePayload>(query.queryKey, input.payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> =
|
||||
> & {
|
||||
dataShape: "list" | "value";
|
||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||
staleTimeMs: number;
|
||||
};
|
||||
} & (
|
||||
| { staleTimeMs: number; immutableWhen?: never }
|
||||
| { staleTimeMs?: never; immutableWhen: (data: TQueryFnData) => boolean }
|
||||
);
|
||||
|
||||
export function useReplicaQuery<
|
||||
TQueryFnData,
|
||||
@@ -88,7 +90,7 @@ function replicaQueryOptions<
|
||||
};
|
||||
}
|
||||
|
||||
function fetchQueryOptions<
|
||||
export function fetchQueryOptions<
|
||||
TQueryFnData,
|
||||
TError = Error,
|
||||
TData = TQueryFnData,
|
||||
@@ -96,11 +98,11 @@ function fetchQueryOptions<
|
||||
>(
|
||||
input: FetchQueryInput<TQueryFnData, TError, TData, TQueryKey>,
|
||||
): UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> {
|
||||
if (!Number.isFinite(input.staleTimeMs)) {
|
||||
if (!input.immutableWhen && !Number.isFinite(input.staleTimeMs)) {
|
||||
throw new Error("Fetch queries must declare a finite staleTimeMs.");
|
||||
}
|
||||
|
||||
const { dataShape, meta, staleTimeMs, ...options } = input;
|
||||
const { dataShape, meta, staleTimeMs, immutableWhen, ...options } = input;
|
||||
return {
|
||||
...options,
|
||||
...(dataShape === "list" ? { placeholderData: keepPreviousData } : {}),
|
||||
@@ -111,7 +113,10 @@ function fetchQueryOptions<
|
||||
dataShape,
|
||||
},
|
||||
},
|
||||
refetchOnMount: "always",
|
||||
staleTime: staleTimeMs,
|
||||
refetchOnMount: immutableWhen ? true : "always",
|
||||
staleTime: immutableWhen
|
||||
? (query) =>
|
||||
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
||||
: staleTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,10 +14,9 @@ import {
|
||||
} from "@/desktop/updates/resolve-update-callout";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { openChangelog } from "@/changelog";
|
||||
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function renderBody(body: UpdateCalloutBody, t: ReturnType<typeof useTranslation>["t"]): ReactNode {
|
||||
if (body.kind === "installing") return t("desktop.updates.callout.installingDescription");
|
||||
@@ -52,9 +51,6 @@ export function UpdateCalloutSource() {
|
||||
} = useDesktopAppUpdater();
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const openChangelog = useStableEvent(() => {
|
||||
void openExternalUrl(CHANGELOG_URL);
|
||||
});
|
||||
const install = useStableEvent(() => {
|
||||
void installUpdate();
|
||||
});
|
||||
@@ -111,7 +107,6 @@ export function UpdateCalloutSource() {
|
||||
install,
|
||||
isDesktopApp,
|
||||
isInstalling,
|
||||
openChangelog,
|
||||
retry,
|
||||
status,
|
||||
theme.colors.foregroundMuted,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fetchQueryOptions } from "@/data/query";
|
||||
import { commitFileDiffQueryOptions } from "./commit-file-diff-query";
|
||||
|
||||
const file = { path: "a.ts", isNew: true, isDeleted: false, additions: 0, deletions: 0, hunks: [] };
|
||||
|
||||
describe("commit file diff fetching", () => {
|
||||
it("reuses a cached commit body on remount and fetches on explicit refresh", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
let calls = 0;
|
||||
const options = fetchQueryOptions(
|
||||
commitFileDiffQueryOptions({
|
||||
serverId: "host",
|
||||
cwd: "/repo",
|
||||
sha: "abc",
|
||||
path: "a.ts",
|
||||
enabled: true,
|
||||
client: {
|
||||
async getCommitFileDiff() {
|
||||
calls++;
|
||||
return { file };
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
options.queryKey,
|
||||
{ file },
|
||||
{ updatedAt: Date.now() - 24 * 60 * 60 * 1000 },
|
||||
);
|
||||
const observer = new QueryObserver(queryClient, options);
|
||||
const detach = observer.subscribe(() => {});
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(calls).toBe(0);
|
||||
expect(observer.getCurrentResult().data?.file).toBe(file);
|
||||
await observer.refetch();
|
||||
expect(calls).toBe(1);
|
||||
} finally {
|
||||
detach();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries a cached null result when reopened", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const options = fetchQueryOptions(
|
||||
commitFileDiffQueryOptions({
|
||||
serverId: "host",
|
||||
cwd: "/repo",
|
||||
sha: "abc",
|
||||
path: "a.ts",
|
||||
enabled: true,
|
||||
client: {
|
||||
async getCommitFileDiff() {
|
||||
return { file };
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
queryClient.setQueryData(options.queryKey, { file: null });
|
||||
const observer = new QueryObserver(queryClient, options);
|
||||
const settled = Promise.withResolvers<void>();
|
||||
const detach = observer.subscribe((result) => {
|
||||
if (result.fetchStatus === "idle") settled.resolve();
|
||||
});
|
||||
try {
|
||||
await settled.promise;
|
||||
expect(observer.getCurrentResult().data?.file).toEqual(file);
|
||||
} finally {
|
||||
detach();
|
||||
queryClient.clear();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "./query-keys";
|
||||
import { shareCommitFileDiff } from "./diff-sharing";
|
||||
|
||||
export function commitFileDiffQueryOptions(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
sha: string;
|
||||
path: string;
|
||||
client: Pick<DaemonClient, "getCommitFileDiff"> | null;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
return {
|
||||
queryKey: checkoutCommitFileDiffQueryKey(input.serverId, input.cwd, input.sha, input.path),
|
||||
queryFn: () => {
|
||||
if (!input.client) throw new Error("Host disconnected");
|
||||
return input.client.getCommitFileDiff(input.cwd, input.sha, input.path);
|
||||
},
|
||||
enabled: input.enabled,
|
||||
gcTime: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
// Only successful file bodies are immutable. Null/error results can recover.
|
||||
immutableWhen: (data: Awaited<ReturnType<DaemonClient["getCommitFileDiff"]>>) =>
|
||||
data.file !== null,
|
||||
dataShape: "value" as const,
|
||||
refetchOnWindowFocus: false,
|
||||
structuralSharing: shareCommitFileDiff,
|
||||
};
|
||||
}
|
||||
@@ -26,9 +26,10 @@ export function HorizontalScroll({
|
||||
const scrollId = useId();
|
||||
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
|
||||
const edgeRef = useRef(true);
|
||||
const path = file.path;
|
||||
const handler = useAnimatedScrollHandler({
|
||||
onScroll(event) {
|
||||
offsets.value = { ...offsets.value, [file.path]: event.contentOffset.x };
|
||||
offsets.value = { ...offsets.value, [path]: event.contentOffset.x };
|
||||
},
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -71,6 +71,55 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
||||
}
|
||||
|
||||
describe("diff document model", () => {
|
||||
it("materializes only visible rows of a large file while preserving full horizontal extent", () => {
|
||||
const source = file("large.ts");
|
||||
source.hunks[0]!.lines = Array.from({ length: 1000 }, (_, index) => ({
|
||||
type: "add",
|
||||
content: index === 900 ? "a very wide offscreen line ".repeat(20) : `line ${index}`,
|
||||
tokens: [{ text: `line ${index}`, style: null }],
|
||||
}));
|
||||
let advancedCharacters = 0;
|
||||
const measureText: TextMeasurer = {
|
||||
measure: (text) => text.length * 10,
|
||||
measureAdvances(graphemes) {
|
||||
advancedCharacters += graphemes.join("").length;
|
||||
return graphemes.map((_, index) => (index + 1) * 10);
|
||||
},
|
||||
};
|
||||
const base = input({ files: [source], wrapLines: false, viewportWidth: 300, measureText });
|
||||
const eager = buildDiffDocumentModel(base);
|
||||
advancedCharacters = 0;
|
||||
const first = buildDiffDocumentModel({
|
||||
...base,
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
|
||||
});
|
||||
expect(measuredFragments(first, source.path)).toBe(3);
|
||||
expect(advancedCharacters).toBeLessThan(100);
|
||||
expect(first.height).toBe(eager.height);
|
||||
expect(first.files[0]!.contentWidth).toBe(eager.files[0]!.contentWidth);
|
||||
const priorCells = firstLineCells(first, source.path);
|
||||
advancedCharacters = 0;
|
||||
const next = buildDiffDocumentModel({
|
||||
...base,
|
||||
reuseFrom: [first],
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT + 900 * 18, height: 54 },
|
||||
});
|
||||
expect(firstLineCells(next, source.path)).toBe(priorCells);
|
||||
expect(next.rows[900]).toMatchObject({
|
||||
kind: "line",
|
||||
cells: [{ fragments: [{ text: source.hunks[0]!.lines[900]!.content }] }],
|
||||
});
|
||||
expect(advancedCharacters).toBeLessThan(600);
|
||||
expect(next.height).toBe(eager.height);
|
||||
advancedCharacters = 0;
|
||||
buildDiffDocumentModel({
|
||||
...base,
|
||||
reuseFrom: [next],
|
||||
materializationWindow: { top: FILE_HEADER_HEIGHT, height: 54 },
|
||||
});
|
||||
expect(advancedCharacters).toBe(0);
|
||||
});
|
||||
|
||||
it("does not imperatively scroll for a no-op relayout", () => {
|
||||
expect(shouldApplyRelayoutScroll(320, 320)).toBe(false);
|
||||
expect(shouldApplyRelayoutScroll(320, 320.4)).toBe(false);
|
||||
|
||||
@@ -157,21 +157,26 @@ function appendReusableFileRows(candidate: {
|
||||
!candidate.input.wrapLines &&
|
||||
candidate.model.reviewGeometryKey === candidate.reviewGeometryKey &&
|
||||
candidate.file.top === candidate.fileTop &&
|
||||
candidate.file.fileIndex === candidate.fileIndex &&
|
||||
candidate.file.rowStart === candidate.rows.length;
|
||||
if (retainsRowGeometry) {
|
||||
candidate.rows.push(...reusedRows);
|
||||
return {
|
||||
bottom: candidate.bodyTop + candidate.file.bodyHeight,
|
||||
maximumHorizontalOverflow: Math.max(
|
||||
0,
|
||||
candidate.file.contentWidth - candidate.input.viewportWidth,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let top = candidate.bodyTop;
|
||||
for (const reusedRow of reusedRows) {
|
||||
const nextRow = repositionReusedRow(reusedRow, candidate, top);
|
||||
let nextRow = retainsRowGeometry ? reusedRow : repositionReusedRow(reusedRow, candidate, top);
|
||||
if (
|
||||
nextRow.kind === "line" &&
|
||||
intersectsMaterializationWindow(candidate.input, top, top + nextRow.height) &&
|
||||
nextRow.cells.some((cell) => cell && cell.fragments.length === 0)
|
||||
) {
|
||||
nextRow = {
|
||||
...nextRow,
|
||||
cells: materializeCells(
|
||||
nextRow.cells,
|
||||
candidate.file.file,
|
||||
candidate.file.gutterWidth,
|
||||
candidate.input,
|
||||
),
|
||||
};
|
||||
}
|
||||
candidate.rows.push(nextRow);
|
||||
top += nextRow.height;
|
||||
}
|
||||
@@ -232,71 +237,45 @@ function appendNewFileRows(candidate: {
|
||||
};
|
||||
}
|
||||
|
||||
const deferredLines =
|
||||
candidate.input.materializationWindow && !candidate.input.wrapLines
|
||||
? geometryLines(lineSources(candidate.file, candidate.input.layout, false), candidate.input)
|
||||
: null;
|
||||
const deferredBodyHeight = deferredLines?.reduce((height, line) => height + line.height, 0) ?? 0;
|
||||
const shouldMaterialize =
|
||||
deferredLines === null ||
|
||||
intersectsMaterializationWindow(
|
||||
candidate.input,
|
||||
candidate.fileTop,
|
||||
candidate.bodyTop + deferredBodyHeight + DIFF_BODY_BORDER_HEIGHT,
|
||||
);
|
||||
return shouldMaterialize
|
||||
? appendMeasuredFileRows(candidate)
|
||||
: appendGeometryFileRows(candidate, deferredLines);
|
||||
}
|
||||
|
||||
function appendGeometryFileRows(
|
||||
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||
lines: readonly GeometryLine[],
|
||||
): FileRowsResult {
|
||||
let top = candidate.bodyTop;
|
||||
for (const line of lines) {
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
fileIndex: candidate.fileIndex,
|
||||
path: candidate.file.path,
|
||||
top,
|
||||
height: line.height,
|
||||
cells: line.cells,
|
||||
reviewHeight: line.reviewHeight,
|
||||
});
|
||||
top += line.height;
|
||||
}
|
||||
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow: 0 };
|
||||
}
|
||||
|
||||
function appendMeasuredFileRows(
|
||||
candidate: Parameters<typeof appendNewFileRows>[0],
|
||||
): FileRowsResult {
|
||||
const lines = geometryLines(
|
||||
lineSources(candidate.file, candidate.input.layout, false),
|
||||
candidate.input,
|
||||
);
|
||||
const fileBottom =
|
||||
candidate.bodyTop +
|
||||
lines.reduce((height, line) => height + line.height, 0) +
|
||||
DIFF_BODY_BORDER_HEIGHT;
|
||||
const measureWidth =
|
||||
!candidate.input.wrapLines &&
|
||||
intersectsMaterializationWindow(candidate.input, candidate.fileTop, fileBottom);
|
||||
let top = candidate.bodyTop;
|
||||
let maximumHorizontalOverflow = 0;
|
||||
for (const sourceCells of lineSources(candidate.file, candidate.input.layout, true)) {
|
||||
const columnCount = sourceCells.length;
|
||||
const columnWidth = candidate.input.viewportWidth / columnCount;
|
||||
const availableWidth = Math.max(
|
||||
candidate.input.measureText.measure("M"),
|
||||
columnWidth - candidate.gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
const cells = sourceCells.map((source) => {
|
||||
if (!source) return null;
|
||||
const cell = measureCell({ source, availableWidth, input: candidate.input });
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + measureCellWidth(cell) - columnWidth,
|
||||
);
|
||||
return cell;
|
||||
}) as DiffLineRow["cells"];
|
||||
for (const line of lines) {
|
||||
const shouldMaterialize =
|
||||
candidate.input.wrapLines ||
|
||||
intersectsMaterializationWindow(candidate.input, top, top + line.height);
|
||||
const cells = shouldMaterialize
|
||||
? materializeCells(line.cells, candidate.file, candidate.gutterWidth, candidate.input)
|
||||
: line.cells;
|
||||
const columnWidth = candidate.input.viewportWidth / cells.length;
|
||||
if (measureWidth) {
|
||||
for (const cell of cells) {
|
||||
if (!cell) continue;
|
||||
const width =
|
||||
cell.fragments.length > 0
|
||||
? measureCellWidth(cell)
|
||||
: candidate.input.measureText.measure(displayLineText(cell.content));
|
||||
maximumHorizontalOverflow = Math.max(
|
||||
maximumHorizontalOverflow,
|
||||
candidate.gutterWidth + CODE_HORIZONTAL_PADDING + width - columnWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
const textHeight = Math.max(
|
||||
candidate.input.typography.lineHeight,
|
||||
...cells.map((cell) => (cell?.fragments.length ?? 1) * candidate.input.typography.lineHeight),
|
||||
);
|
||||
const reviewHeight = reviewHeightForCells(cells, candidate.input);
|
||||
const height = textHeight + reviewHeight;
|
||||
const height = textHeight + line.reviewHeight;
|
||||
candidate.rows.push({
|
||||
kind: "line",
|
||||
index: candidate.rows.length,
|
||||
@@ -305,13 +284,46 @@ function appendMeasuredFileRows(
|
||||
top,
|
||||
height,
|
||||
cells,
|
||||
reviewHeight,
|
||||
reviewHeight: line.reviewHeight,
|
||||
});
|
||||
top += height;
|
||||
}
|
||||
return { bottom: top + DIFF_BODY_BORDER_HEIGHT, maximumHorizontalOverflow };
|
||||
}
|
||||
|
||||
function materializeCells(
|
||||
cells: DiffLineRow["cells"],
|
||||
file: BuildDiffDocumentModelInput["files"][number],
|
||||
gutterWidth: number,
|
||||
input: BuildDiffDocumentModelInput,
|
||||
): DiffLineRow["cells"] {
|
||||
const availableWidth = Math.max(
|
||||
input.measureText.measure("M"),
|
||||
input.viewportWidth / cells.length - gutterWidth - CODE_HORIZONTAL_PADDING,
|
||||
);
|
||||
return cells.map((cell) => {
|
||||
if (!cell || cell.fragments.length > 0) return cell;
|
||||
const sourceLine =
|
||||
file.hunks[cell.sourceIdentity.hunkIndex]!.lines[cell.sourceIdentity.lineIndex]!;
|
||||
return measureCell({
|
||||
source: {
|
||||
...cell,
|
||||
tokenText: cell.type === "header" ? [] : compactHighlightTokens(sourceLine.tokens ?? []),
|
||||
},
|
||||
availableWidth,
|
||||
input,
|
||||
});
|
||||
}) as DiffLineRow["cells"];
|
||||
}
|
||||
|
||||
function displayLineText(text: string): string {
|
||||
return text.includes("\t")
|
||||
? segmentGraphemes(text)
|
||||
.map((segment) => segment.text)
|
||||
.join("")
|
||||
: text;
|
||||
}
|
||||
|
||||
export function reviewGeometryKey(
|
||||
reviewActions: BuildDiffDocumentModelInput["reviewActions"],
|
||||
): string {
|
||||
@@ -334,7 +346,11 @@ export function retainReusableModels(
|
||||
previous: readonly DiffDocumentModel[] | undefined,
|
||||
next: DiffDocumentModel,
|
||||
): DiffDocumentModel[] {
|
||||
const fullest = [...(previous ?? []), next].reduce((best, candidate) =>
|
||||
const sources = new Set(next.files.map((file) => file.file));
|
||||
const compatible = previous?.filter((model) =>
|
||||
model.files.every((file) => sources.has(file.file)),
|
||||
);
|
||||
const fullest = [...(compatible ?? []), next].reduce((best, candidate) =>
|
||||
measuredFileCount(candidate) > measuredFileCount(best) ? candidate : best,
|
||||
);
|
||||
return fullest === next ? [next] : [fullest, next];
|
||||
@@ -437,16 +453,17 @@ function intersectsMaterializationWindow(
|
||||
|
||||
function hasMeasuredFileRows(model: DiffDocumentModel, file: DiffFileSection): boolean {
|
||||
if (file.isCollapsed) return false;
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).every((row) => {
|
||||
return model.rows.slice(file.rowStart, file.rowEnd).some((row) => {
|
||||
if (row.kind !== "line") return true;
|
||||
return row.cells.every((cell) => !cell || cell.fragments.length > 0);
|
||||
return row.cells.some((cell) => cell && cell.fragments.length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
function indexReusableModels(
|
||||
models: readonly DiffDocumentModel[] | undefined,
|
||||
): ReusableModelIndex[] {
|
||||
return (models ?? []).map((model) => {
|
||||
// The latest model includes text prepared by later scroll windows.
|
||||
return (models ?? []).toReversed().map((model) => {
|
||||
const filesBySource = new Map<BuildDiffDocumentModelInput["files"][number], DiffFileSection>();
|
||||
const measuredFiles = new Set<DiffFileSection>();
|
||||
for (const file of model.files) {
|
||||
|
||||
@@ -110,7 +110,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
|
||||
const model = useMemo(() => {
|
||||
const dependencies = [
|
||||
props.files,
|
||||
props.displayPreferences.layout,
|
||||
props.displayPreferences.wrapLines,
|
||||
viewport.width,
|
||||
@@ -172,8 +171,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
||||
[family, props.palette, typography.lineHeight, typography.size],
|
||||
);
|
||||
const textLayout = useMemo(
|
||||
() => prepareNativeTextLayout(textLayoutStore, model),
|
||||
[model, textLayoutStore],
|
||||
() =>
|
||||
prepareNativeTextLayout(
|
||||
textLayoutStore,
|
||||
model,
|
||||
diffMaterializationWindow(fileWindowTop, viewport.height),
|
||||
),
|
||||
[model, textLayoutStore, fileWindowTop, viewport.height],
|
||||
);
|
||||
useEffect(() => () => disposeNativeTextLayout(textLayoutStore), [textLayoutStore]);
|
||||
const headerTextLayout = useMemo(
|
||||
@@ -386,11 +390,12 @@ function NativeCanvasFileHeader({
|
||||
() => recordNativeHeaderPicture({ file, viewportWidth, textLayout, paints }),
|
||||
[file, paints, textLayout, viewportWidth],
|
||||
);
|
||||
const { top, bottom, headerHeight } = file;
|
||||
const stickyStyle = useAnimatedStyle(() => {
|
||||
const pinOffset = Math.max(0, scrollTop.value - file.top);
|
||||
const maximumPinOffset = Math.max(0, file.bottom - file.headerHeight - file.top);
|
||||
const pinOffset = Math.max(0, scrollTop.value - top);
|
||||
const maximumPinOffset = Math.max(0, bottom - headerHeight - top);
|
||||
return { transform: [{ translateY: Math.min(pinOffset, maximumPinOffset) }] };
|
||||
}, [file.bottom, file.headerHeight, file.top, scrollTop]);
|
||||
}, [bottom, headerHeight, top, scrollTop]);
|
||||
const style = useMemo<ViewStyle>(
|
||||
() => ({
|
||||
position: "absolute",
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("cached ASCII text metrics", () => {
|
||||
|
||||
expect(metrics.measureAdvances(["a", "b", "a"])).toEqual([10, 20, 30]);
|
||||
expect(metrics.measureAdvances(["b", "a"])).toEqual([10, 20]);
|
||||
expect(metrics.measure("abba")).toBe(40);
|
||||
expect(measured).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface PrimaryTextFace {
|
||||
}
|
||||
|
||||
export interface CachedAsciiTextMetrics {
|
||||
/** Whole-line width after checking glyph coverage and excluding shaping. */
|
||||
measure(text: string): number;
|
||||
hasEveryGlyph(text: string): boolean;
|
||||
measureAdvances(graphemes: readonly string[]): number[];
|
||||
}
|
||||
@@ -97,24 +99,39 @@ export function advancesFor(measurer: TextMeasurer): (graphemes: readonly string
|
||||
export function createAdditiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return additiveAdvances(cachedWidths(measure));
|
||||
}
|
||||
|
||||
function cachedWidths(measure: (text: string) => number): (text: string) => number {
|
||||
const widths = new Map<string, number>();
|
||||
return (text) => {
|
||||
let width = widths.get(text);
|
||||
if (width === undefined) {
|
||||
width = measure(text);
|
||||
widths.set(text, width);
|
||||
}
|
||||
return width;
|
||||
};
|
||||
}
|
||||
|
||||
function additiveAdvances(
|
||||
measure: (text: string) => number,
|
||||
): (graphemes: readonly string[]) => number[] {
|
||||
return (graphemes) => {
|
||||
let advance = 0;
|
||||
return graphemes.map((grapheme) => {
|
||||
let width = widths.get(grapheme);
|
||||
if (width === undefined) {
|
||||
width = measure(grapheme);
|
||||
widths.set(grapheme, width);
|
||||
}
|
||||
advance += width;
|
||||
return advance;
|
||||
});
|
||||
return graphemes.map((grapheme) => (advance += measure(grapheme)));
|
||||
};
|
||||
}
|
||||
|
||||
export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAsciiTextMetrics {
|
||||
const glyphCoverage = new Map<string, boolean>();
|
||||
const measure = cachedWidths((text) => primary.measure(text));
|
||||
return {
|
||||
measure(text) {
|
||||
let width = 0;
|
||||
for (const character of text) width += measure(character);
|
||||
return width;
|
||||
},
|
||||
hasEveryGlyph(text) {
|
||||
for (const character of text) {
|
||||
let hasGlyph = glyphCoverage.get(character);
|
||||
@@ -126,7 +143,7 @@ export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAs
|
||||
}
|
||||
return true;
|
||||
},
|
||||
measureAdvances: createAdditiveAdvances((text) => primary.measure(text)),
|
||||
measureAdvances: additiveAdvances(measure),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,11 @@ import {
|
||||
type SkFont,
|
||||
type SkParagraph,
|
||||
} from "@shopify/react-native-skia";
|
||||
import { fragmentTextForRange } from "./model";
|
||||
import { fragmentTextForRange, visibleRowRange } from "./model";
|
||||
import { codeTextColor } from "./palette";
|
||||
import {
|
||||
createCachedAsciiTextMetrics,
|
||||
createChunkedAdvanceMeasurer,
|
||||
createFallbackAwareTextMeasurer,
|
||||
requiresShaping,
|
||||
type CachedAsciiTextMetrics,
|
||||
} from "./text-measurement";
|
||||
@@ -86,7 +85,8 @@ export function shapeNativeHeaderText(input: {
|
||||
export interface NativeTextLayoutStore {
|
||||
font: SkFont;
|
||||
asciiMetrics: CachedAsciiTextMetrics;
|
||||
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
|
||||
paragraphsByCell: Map<DiffCell, Array<SkParagraph | null>>;
|
||||
textStyles: Map<string, ReturnType<typeof textStyle>>;
|
||||
ownedParagraphs: Set<SkParagraph>;
|
||||
families: string[];
|
||||
fontSize: number;
|
||||
@@ -97,6 +97,8 @@ export interface NativeTextLayoutStore {
|
||||
export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void {
|
||||
for (const paragraph of layout.ownedParagraphs) paragraph.dispose();
|
||||
layout.ownedParagraphs.clear();
|
||||
layout.paragraphsByCell.clear();
|
||||
layout.textStyles.clear();
|
||||
layout.font.dispose();
|
||||
}
|
||||
|
||||
@@ -114,7 +116,8 @@ export function createNativeTextLayoutStore(input: {
|
||||
glyphIds: (text) => font.getGlyphIDs(text),
|
||||
measure: (text) => font.getTextWidth(text),
|
||||
}),
|
||||
paragraphsByCell: new WeakMap(),
|
||||
paragraphsByCell: new Map(),
|
||||
textStyles: new Map(),
|
||||
ownedParagraphs: new Set(),
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
@@ -126,30 +129,64 @@ export function createNativeTextLayoutStore(input: {
|
||||
export function prepareNativeTextLayout(
|
||||
store: NativeTextLayoutStore,
|
||||
model: DiffDocumentModel,
|
||||
window?: { top: number; height: number },
|
||||
): NativeTextLayout {
|
||||
const paragraphs = model.rows.map((row) => {
|
||||
if (row.kind !== "line") return [];
|
||||
return row.cells.map((cell) => {
|
||||
const range = window
|
||||
? visibleRowRange(model.rows, window.top, window.height)
|
||||
: { start: 0, end: model.rows.length };
|
||||
const retainedCells = new Set<DiffCell>();
|
||||
const retainedParagraphs = new Set<SkParagraph>();
|
||||
const paragraphs: NativeTextLayout["paragraphs"] = [];
|
||||
paragraphs.length = model.rows.length;
|
||||
for (let index = range.start; index < range.end; index++) {
|
||||
const row = model.rows[index]!;
|
||||
if (row.kind !== "line") continue;
|
||||
paragraphs[index] = row.cells.map((cell) => {
|
||||
if (!cell) return [];
|
||||
retainedCells.add(cell);
|
||||
const cached = store.paragraphsByCell.get(cell);
|
||||
if (cached) return cached;
|
||||
const cellParagraphs = cell.fragments.map((fragment) => {
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) return null;
|
||||
const paragraph = createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
});
|
||||
const start = window ? Math.max(0, Math.floor((window.top - row.top) / model.lineHeight)) : 0;
|
||||
const end = window
|
||||
? Math.min(
|
||||
cell.fragments.length,
|
||||
Math.ceil((window.top + window.height - row.top) / model.lineHeight),
|
||||
)
|
||||
: cell.fragments.length;
|
||||
const cellParagraphs: Array<SkParagraph | null> = [];
|
||||
cellParagraphs.length = cell.fragments.length;
|
||||
for (let fragmentIndex = start; fragmentIndex < end; fragmentIndex++) {
|
||||
const fragment = cell.fragments[fragmentIndex]!;
|
||||
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) {
|
||||
cellParagraphs[fragmentIndex] = null;
|
||||
continue;
|
||||
}
|
||||
const paragraph =
|
||||
cached?.[fragmentIndex] ??
|
||||
createFragmentParagraph({
|
||||
cell,
|
||||
fragment,
|
||||
families: store.families,
|
||||
fontSize: store.fontSize,
|
||||
lineHeight: store.lineHeight,
|
||||
palette: store.palette,
|
||||
textStyles: store.textStyles,
|
||||
});
|
||||
store.ownedParagraphs.add(paragraph);
|
||||
return paragraph;
|
||||
});
|
||||
retainedParagraphs.add(paragraph);
|
||||
cellParagraphs[fragmentIndex] = paragraph;
|
||||
}
|
||||
store.paragraphsByCell.set(cell, cellParagraphs);
|
||||
return cellParagraphs;
|
||||
});
|
||||
});
|
||||
}
|
||||
for (const paragraph of store.ownedParagraphs) {
|
||||
if (retainedParagraphs.has(paragraph)) continue;
|
||||
paragraph.dispose();
|
||||
store.ownedParagraphs.delete(paragraph);
|
||||
}
|
||||
for (const cell of store.paragraphsByCell.keys()) {
|
||||
if (!retainedCells.has(cell)) store.paragraphsByCell.delete(cell);
|
||||
}
|
||||
return { font: store.font, paragraphs };
|
||||
}
|
||||
|
||||
@@ -164,23 +201,25 @@ export function createNativeTextMeasurer(input: {
|
||||
measure: (text: string) => font.getTextWidth(text),
|
||||
};
|
||||
const asciiMetrics = createCachedAsciiTextMetrics(primary);
|
||||
const fallbackAware = createFallbackAwareTextMeasurer({
|
||||
primary,
|
||||
measureWithSystemFallback(text) {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
},
|
||||
});
|
||||
const black = Skia.Color("black");
|
||||
const measureParagraph = (text: string) => {
|
||||
const paragraph = createParagraph({
|
||||
text,
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: black,
|
||||
});
|
||||
const width = paragraph.getLongestLine();
|
||||
paragraph.dispose();
|
||||
return width;
|
||||
};
|
||||
return {
|
||||
...fallbackAware,
|
||||
measure(text) {
|
||||
return requiresRetainedParagraph(text, asciiMetrics)
|
||||
? measureParagraph(text)
|
||||
: asciiMetrics.measure(text);
|
||||
},
|
||||
measureAdvances: createChunkedAdvanceMeasurer({
|
||||
requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics),
|
||||
measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes),
|
||||
@@ -190,7 +229,7 @@ export function createNativeTextMeasurer(input: {
|
||||
families,
|
||||
fontSize: input.fontSize,
|
||||
lineHeight: Math.round(input.fontSize * 1.5),
|
||||
color: Skia.Color("black"),
|
||||
color: black,
|
||||
});
|
||||
let end = 0;
|
||||
const advances = graphemes.map((grapheme) => {
|
||||
@@ -219,23 +258,21 @@ function createFragmentParagraph(input: {
|
||||
fontSize: number;
|
||||
lineHeight: number;
|
||||
palette: DiffPalette;
|
||||
textStyles: NativeTextLayoutStore["textStyles"];
|
||||
}): SkParagraph {
|
||||
const builder = Skia.ParagraphBuilder.Make(
|
||||
paragraphStyle(input.families, input.fontSize, input.lineHeight),
|
||||
);
|
||||
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
|
||||
const color = codeTextColor(input.cell, input.palette);
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, color))
|
||||
.addText(input.fragment.text)
|
||||
.pop();
|
||||
builder.pushStyle(retainedTextStyle(input, color)).addText(input.fragment.text).pop();
|
||||
} else {
|
||||
for (const run of input.cell.tokens) {
|
||||
const start = Math.max(input.fragment.start, run.start);
|
||||
const end = Math.min(input.fragment.end, run.end);
|
||||
if (end <= start) continue;
|
||||
builder
|
||||
.pushStyle(textStyle(input.families, input.fontSize, run.color))
|
||||
.pushStyle(retainedTextStyle(input, run.color))
|
||||
.addText(fragmentTextForRange(input.fragment, start, end))
|
||||
.pop();
|
||||
}
|
||||
@@ -281,6 +318,18 @@ function paragraphStyle(families: string[], fontSize: number, lineHeight: number
|
||||
};
|
||||
}
|
||||
|
||||
function retainedTextStyle(
|
||||
input: { families: string[]; fontSize: number; textStyles: NativeTextLayoutStore["textStyles"] },
|
||||
color: string,
|
||||
) {
|
||||
let style = input.textStyles.get(color);
|
||||
if (!style) {
|
||||
style = textStyle(input.families, input.fontSize, color);
|
||||
input.textStyles.set(color, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function textStyle(families: string[], fontSize: number, color: string) {
|
||||
return { fontFamilies: families, fontSize, color: Skia.Color(color) };
|
||||
}
|
||||
|
||||
@@ -78,6 +78,40 @@ function modelInput(
|
||||
}
|
||||
|
||||
describe("diff document workspace cache", () => {
|
||||
it("keeps text prepared in each visited window of the same file", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const source = diffFile();
|
||||
source.hunks[0]!.lines = Array.from({ length: 100 }, (_, index) => ({
|
||||
type: "add",
|
||||
content: `const value${index} = ${index};`,
|
||||
}));
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const input = modelInput([source], measureText);
|
||||
for (const top of [30, 300, 600]) {
|
||||
cache.buildModel({ ...input, materializationWindow: { top, height: 54 } });
|
||||
}
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, materializationWindow: { top: 300, height: 54 } });
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("retains text cells for unchanged files when another file changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const { measureText } = countingMeasurer();
|
||||
const files = [diffFile(), { ...diffFile(), path: "b.ts" }];
|
||||
const first = cache.buildModel(modelInput(files, measureText));
|
||||
const replacement = structuredClone(files[0]!);
|
||||
replacement.hunks[0]!.lines[0]!.content = "changed text";
|
||||
const next = cache.buildModel(modelInput([replacement, files[1]!], measureText));
|
||||
const oldRow = first.rows[first.files[1]!.rowStart]!;
|
||||
const nextRow = next.rows[next.files[1]!.rowStart]!;
|
||||
expect(oldRow.kind).toBe("line");
|
||||
expect(nextRow.kind).toBe("line");
|
||||
if (oldRow.kind !== "line" || nextRow.kind !== "line") throw new Error("Missing file text");
|
||||
expect(nextRow.cells).toBe(oldRow.cells);
|
||||
expect(next.files[0]!.file).toBe(replacement);
|
||||
});
|
||||
|
||||
it("reuses measured rows until a model-building input changes", () => {
|
||||
const cache = createDiffDocumentWorkspaceCache();
|
||||
const files = [diffFile()];
|
||||
@@ -115,7 +149,7 @@ describe("diff document workspace cache", () => {
|
||||
|
||||
stats.calls = 0;
|
||||
cache.buildModel({ ...input, files: [...files] });
|
||||
expect(stats.calls).toBeGreaterThan(0);
|
||||
expect(stats.calls).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds retained geometry variants for one diff payload", () => {
|
||||
@@ -152,7 +186,7 @@ describe("diff document workspace cache", () => {
|
||||
const files = [diffFile(), { ...diffFile(), path: "src/b.ts" }];
|
||||
const { measureText, stats } = countingMeasurer();
|
||||
const first = cache.buildModel(
|
||||
modelInput(files, measureText, { materializationWindow: { top: 0, height: 20 } }),
|
||||
modelInput(files, measureText, { materializationWindow: { top: 30, height: 20 } }),
|
||||
);
|
||||
const firstCalls = stats.calls;
|
||||
const secondFile = first.files[1]!;
|
||||
|
||||
@@ -13,6 +13,7 @@ const MAX_MODEL_VARIANTS = 4;
|
||||
const MAX_TYPOGRAPHY_RESOURCES = 4;
|
||||
|
||||
interface ModelVariant {
|
||||
measureText: TextMeasurer;
|
||||
key: string;
|
||||
exactKey: string;
|
||||
model: DiffDocumentModel;
|
||||
@@ -38,23 +39,29 @@ export interface DiffDocumentWorkspaceCache {
|
||||
}
|
||||
|
||||
export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
const variantsByFiles = new WeakMap<BuildDiffDocumentModelInput["files"], ModelVariant[]>();
|
||||
let variants: ModelVariant[] = [];
|
||||
const typographyResources = new Map<string, DiffTypographyResource>();
|
||||
|
||||
return {
|
||||
buildModel(input) {
|
||||
const key = modelVariantKey(input);
|
||||
const exactKey = exactModelKey(input);
|
||||
const variants = variantsByFiles.get(input.files) ?? [];
|
||||
const variantIndex = variants.findIndex((candidate) => candidate.key === key);
|
||||
const variantIndex = variants.findIndex(
|
||||
(candidate) => candidate.key === key && candidate.measureText === input.measureText,
|
||||
);
|
||||
const variant = variantIndex === -1 ? undefined : variants[variantIndex];
|
||||
if (variant?.exactKey === exactKey) {
|
||||
if (
|
||||
variant?.exactKey === exactKey &&
|
||||
variant.model.files.length === input.files.length &&
|
||||
variant.model.files.every((file, index) => file.file === input.files[index])
|
||||
) {
|
||||
variants.splice(variantIndex, 1);
|
||||
variants.unshift(variant);
|
||||
return variant.model;
|
||||
}
|
||||
const model = buildDiffDocumentModel({ ...input, reuseFrom: variant?.models });
|
||||
const nextVariant = {
|
||||
measureText: input.measureText,
|
||||
key,
|
||||
exactKey,
|
||||
model,
|
||||
@@ -62,7 +69,7 @@ export function createDiffDocumentWorkspaceCache(): DiffDocumentWorkspaceCache {
|
||||
};
|
||||
if (variantIndex !== -1) variants.splice(variantIndex, 1);
|
||||
variants.unshift(nextVariant);
|
||||
variantsByFiles.set(input.files, variants.slice(0, MAX_MODEL_VARIANTS));
|
||||
variants = variants.slice(0, MAX_MODEL_VARIANTS);
|
||||
return model;
|
||||
},
|
||||
typography(input) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { retainDiffFiles, shareCheckoutDiff, shareCommitFileDiff } from "./diff-sharing";
|
||||
|
||||
const file: ParsedDiffFile = {
|
||||
path: "a.ts",
|
||||
oldPath: "old-a.ts",
|
||||
status: "ok",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
hunks: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldCount: 1,
|
||||
newStart: 1,
|
||||
newCount: 1,
|
||||
lines: [
|
||||
{
|
||||
type: "add",
|
||||
content: "const a",
|
||||
tokens: [
|
||||
{ text: "const", style: "keyword" },
|
||||
{ text: " a", style: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Exhaustive keys make a protocol-field addition require a sharing-policy test.
|
||||
const fileChanges = {
|
||||
path: (value) => {
|
||||
value.path = "b.ts";
|
||||
},
|
||||
oldPath: (value) => {
|
||||
delete value.oldPath;
|
||||
},
|
||||
status: (value) => {
|
||||
value.status = "binary";
|
||||
},
|
||||
isNew: (value) => {
|
||||
value.isNew = true;
|
||||
},
|
||||
isDeleted: (value) => {
|
||||
value.isDeleted = true;
|
||||
},
|
||||
additions: (value) => {
|
||||
value.additions = 2;
|
||||
},
|
||||
deletions: (value) => {
|
||||
value.deletions = 2;
|
||||
},
|
||||
hunks: (value) => {
|
||||
value.hunks = [];
|
||||
},
|
||||
} satisfies Record<keyof ParsedDiffFile, (value: ParsedDiffFile) => void>;
|
||||
type Hunk = ParsedDiffFile["hunks"][number];
|
||||
const hunkChanges = {
|
||||
oldStart: (value) => {
|
||||
value.oldStart = 2;
|
||||
},
|
||||
oldCount: (value) => {
|
||||
value.oldCount = 2;
|
||||
},
|
||||
newStart: (value) => {
|
||||
value.newStart = 2;
|
||||
},
|
||||
newCount: (value) => {
|
||||
value.newCount = 2;
|
||||
},
|
||||
lines: (value) => {
|
||||
value.lines = [];
|
||||
},
|
||||
} satisfies Record<keyof Hunk, (value: Hunk) => void>;
|
||||
type Line = Hunk["lines"][number];
|
||||
const lineChanges = {
|
||||
type: (value) => {
|
||||
value.type = "remove";
|
||||
},
|
||||
content: (value) => {
|
||||
value.content = "const b";
|
||||
},
|
||||
tokens: (value) => {
|
||||
delete value.tokens;
|
||||
},
|
||||
} satisfies Record<keyof Line, (value: Line) => void>;
|
||||
type Token = NonNullable<Line["tokens"]>[number];
|
||||
const tokenChanges = {
|
||||
text: (value) => {
|
||||
value.text = "class";
|
||||
},
|
||||
style: (value) => {
|
||||
value.style = null;
|
||||
},
|
||||
} satisfies Record<keyof Token, (value: Token) => void>;
|
||||
|
||||
describe("diff sharing", () => {
|
||||
it.each(Object.entries(fileChanges))("replaces a file when %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(hunkChanges))("replaces a file when hunk %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(lineChanges))("replaces a file when line %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!.lines[0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it.each(Object.entries(tokenChanges))("replaces a file when token %s changes", (_, change) => {
|
||||
const next = structuredClone(file);
|
||||
change(next.hunks[0]!.lines[0]!.tokens![0]!);
|
||||
expect(retainDiffFiles([file], [next])[0]).toBe(next);
|
||||
});
|
||||
it("retains moved files and removes missing files", () => {
|
||||
const second = { ...file, path: "b.ts" };
|
||||
const reordered = retainDiffFiles([file, second], structuredClone([second, file]));
|
||||
expect(reordered[0]).toBe(second);
|
||||
expect(reordered[1]).toBe(file);
|
||||
expect(retainDiffFiles(reordered, [structuredClone(file)])[0]).toBe(file);
|
||||
});
|
||||
it("updates response metadata without invalidating unchanged text", () => {
|
||||
const previous = { cwd: "/repo", files: [file], error: null, requestId: "a" };
|
||||
expect(shareCheckoutDiff(previous, structuredClone(previous))).toBe(previous);
|
||||
const next = shareCheckoutDiff(previous, { ...structuredClone(previous), requestId: "b" });
|
||||
expect(next.requestId).toBe("b");
|
||||
expect(next.files).toBe(previous.files);
|
||||
const rejected = {
|
||||
...previous,
|
||||
files: [],
|
||||
error: { code: "UNKNOWN", message: "Too large" },
|
||||
diffTooLarge: true,
|
||||
};
|
||||
expect(shareCheckoutDiff(next, rejected)).toEqual(rejected);
|
||||
});
|
||||
it("preserves cached commit bodies and publishes null transitions", () => {
|
||||
const previous = { file };
|
||||
expect(shareCommitFileDiff(previous, structuredClone(previous))).toBe(previous);
|
||||
expect(shareCommitFileDiff(previous, { file: null })).toEqual({ file: null });
|
||||
expect(shareCommitFileDiff({ file: null }, previous)).toBe(previous);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { replaceEqualDeep } from "@tanstack/react-query";
|
||||
import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages";
|
||||
|
||||
type DiffPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
|
||||
/** File identity owns text-model reuse. Never reconstruct the hunk/token graph. */
|
||||
export function retainDiffFiles(
|
||||
previous: ParsedDiffFile[],
|
||||
incoming: ParsedDiffFile[],
|
||||
): ParsedDiffFile[] {
|
||||
if (previous === incoming) return previous;
|
||||
const byPath = new Map(previous.map((file) => [file.path, file]));
|
||||
let unchanged = previous.length === incoming.length;
|
||||
const files = incoming.map((file, index) => {
|
||||
const old = byPath.get(file.path);
|
||||
const retained = old && equalFile(old, file) ? old : file;
|
||||
unchanged &&= retained === previous[index];
|
||||
return retained;
|
||||
});
|
||||
return unchanged ? previous : files;
|
||||
}
|
||||
|
||||
export function shareCheckoutDiff(previous: unknown, incoming: unknown): DiffPayload {
|
||||
const next = incoming as DiffPayload;
|
||||
const old = previous as DiffPayload | undefined;
|
||||
if (!old || old === next) return next;
|
||||
const files = retainDiffFiles(old.files, next.files);
|
||||
const error = replaceEqualDeep(old.error, next.error);
|
||||
return files === old.files &&
|
||||
error === old.error &&
|
||||
old.cwd === next.cwd &&
|
||||
old.requestId === next.requestId &&
|
||||
old.diffTooLarge === next.diffTooLarge
|
||||
? old
|
||||
: { ...next, files, error };
|
||||
}
|
||||
|
||||
export function shareCommitFileDiff(
|
||||
previous: unknown,
|
||||
incoming: unknown,
|
||||
): { file: ParsedDiffFile | null } {
|
||||
const next = incoming as { file: ParsedDiffFile | null };
|
||||
const old = previous as typeof next | undefined;
|
||||
return old &&
|
||||
(old.file === next.file || (old.file && next.file && equalFile(old.file, next.file)))
|
||||
? old
|
||||
: next;
|
||||
}
|
||||
|
||||
function equalFile(left: ParsedDiffFile, right: ParsedDiffFile): boolean {
|
||||
if (left === right) return true;
|
||||
if (
|
||||
left.path !== right.path ||
|
||||
left.oldPath !== right.oldPath ||
|
||||
left.status !== right.status ||
|
||||
left.isNew !== right.isNew ||
|
||||
left.isDeleted !== right.isDeleted ||
|
||||
left.additions !== right.additions ||
|
||||
left.deletions !== right.deletions ||
|
||||
left.hunks.length !== right.hunks.length
|
||||
)
|
||||
return false;
|
||||
for (let h = 0; h < left.hunks.length; h++) {
|
||||
const a = left.hunks[h]!;
|
||||
const b = right.hunks[h]!;
|
||||
if (a === b) continue;
|
||||
if (
|
||||
a.oldStart !== b.oldStart ||
|
||||
a.oldCount !== b.oldCount ||
|
||||
a.newStart !== b.newStart ||
|
||||
a.newCount !== b.newCount ||
|
||||
a.lines.length !== b.lines.length
|
||||
)
|
||||
return false;
|
||||
if (!equalLines(a.lines, b.lines)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function equalLines(
|
||||
left: ParsedDiffFile["hunks"][number]["lines"],
|
||||
right: ParsedDiffFile["hunks"][number]["lines"],
|
||||
): boolean {
|
||||
for (let l = 0; l < left.length; l++) {
|
||||
const x = left[l]!;
|
||||
const y = right[l]!;
|
||||
if (x === y) continue;
|
||||
if (x.type !== y.type || x.content !== y.content) return false;
|
||||
if (x.tokens === y.tokens) continue;
|
||||
if (!x.tokens || !y.tokens || x.tokens.length !== y.tokens.length) return false;
|
||||
for (let t = 0; t < x.tokens.length; t++) {
|
||||
const p = x.tokens[t]!;
|
||||
const q = y.tokens[t]!;
|
||||
if (p.text !== q.text || p.style !== q.style) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys";
|
||||
import { commitFileDiffQueryOptions } from "./commit-file-diff-query";
|
||||
import { useCheckoutCommitsQuery } from "@/git/use-commits-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
@@ -79,18 +79,16 @@ export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFiles
|
||||
Boolean(client) &&
|
||||
isConnected;
|
||||
const fileDiffResults = useFetchQueries(
|
||||
commitFiles.map((file) => ({
|
||||
queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path),
|
||||
queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => {
|
||||
if (!client) {
|
||||
throw new Error("Host disconnected");
|
||||
}
|
||||
return client.getCommitFileDiff(cwd, sha, file.path);
|
||||
},
|
||||
enabled: fileDiffsEnabled,
|
||||
staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME,
|
||||
dataShape: "value" as const,
|
||||
})),
|
||||
commitFiles.map((file) =>
|
||||
commitFileDiffQueryOptions({
|
||||
serverId,
|
||||
cwd,
|
||||
sha,
|
||||
path: file.path,
|
||||
client,
|
||||
enabled: fileDiffsEnabled,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const commitsLoading = commitsQuery.status === "connecting" || commitsQuery.status === "loading";
|
||||
const commitsError = commitsQuery.status === "error" ? commitsQuery.error : null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { shareCheckoutDiff } from "./diff-sharing";
|
||||
import { useMemo } from "react";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
@@ -74,6 +75,7 @@ export function useCheckoutDiffQuery({
|
||||
|
||||
const query = useReplicaQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
structuralSharing: shareCheckoutDiff,
|
||||
enabled: routeEnabled,
|
||||
pushEvent: "checkout_diff_update",
|
||||
meta: checkoutDiffPushRoute({
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export interface ClearWorkspaceAttentionController {
|
||||
hasClearableAttention: boolean;
|
||||
clearAttention: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useClearWorkspaceAttention({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): ClearWorkspaceAttentionController {
|
||||
const hasClearableAttention = useSessionStore((state) => {
|
||||
const workspace = state.sessions[serverId]?.workspaces.get(workspaceId);
|
||||
return workspace?.status === "attention" || workspace?.status === "failed";
|
||||
});
|
||||
|
||||
const clearAttention = useCallback(async () => {
|
||||
if (!hasClearableAttention) {
|
||||
return;
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
await client.clearWorkspaceAttention(workspaceId);
|
||||
}, [hasClearableAttention, serverId, workspaceId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ hasClearableAttention, clearAttention }),
|
||||
[clearAttention, hasClearableAttention],
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,8 @@ export function useProvidersSnapshot(
|
||||
|
||||
const snapshotQuery = useReplicaQuery({
|
||||
queryKey,
|
||||
// The content-hash cache already owns catalog and model identity.
|
||||
structuralSharing: false,
|
||||
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||
pushEvent: "providers_snapshot_update",
|
||||
queryFn: async ({ signal }) => {
|
||||
@@ -149,6 +151,7 @@ export function prefetchProvidersSnapshot(
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: Infinity,
|
||||
structuralSharing: false,
|
||||
queryFn: ({ signal }) =>
|
||||
fetchProvidersSnapshot({ client, serverId, cwd, queryClient: singletonQueryClient, signal }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { markWorkspaceUnread } from "@/workspace/mark-unread";
|
||||
|
||||
export interface WorkspaceReadStateController {
|
||||
hasClearableAttention: boolean;
|
||||
canMarkUnread: boolean;
|
||||
clearAttention: () => Promise<void>;
|
||||
markUnread: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaceReadState({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): WorkspaceReadStateController {
|
||||
const status = useWorkspaceFields(serverId, workspaceId, (workspace) => workspace.status);
|
||||
const supportsMarkUnread = useHostFeature(serverId, "workspaceMarkUnread");
|
||||
const hasClearableAttention = status === "attention" || status === "failed";
|
||||
const canMarkUnread = supportsMarkUnread && status === "done";
|
||||
|
||||
const clearAttention = useCallback(async () => {
|
||||
if (!hasClearableAttention) {
|
||||
return;
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
await client.clearWorkspaceAttention(workspaceId);
|
||||
}, [hasClearableAttention, serverId, workspaceId]);
|
||||
|
||||
const markUnread = useCallback(async () => {
|
||||
if (!canMarkUnread) {
|
||||
return;
|
||||
}
|
||||
await markWorkspaceUnread(serverId, workspaceId);
|
||||
}, [canMarkUnread, serverId, workspaceId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ hasClearableAttention, canMarkUnread, clearAttention, markUnread }),
|
||||
[canMarkUnread, clearAttention, hasClearableAttention, markUnread],
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,11 @@ export function HostBadge({ badge }: { badge: HostBadgeModel }) {
|
||||
testID={`host-badge-${badge.serverId}`}
|
||||
accessibilityLabel={badge.label}
|
||||
>
|
||||
<ThemedServer size={HOST_BADGE_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[badge.color]} />
|
||||
<ThemedServer
|
||||
size={HOST_BADGE_ICON_SIZE}
|
||||
style={styles.icon}
|
||||
uniProps={HOST_ICON_MAPPINGS[badge.color]}
|
||||
/>
|
||||
{badge.showLabel ? (
|
||||
<Text style={[styles.label, labelColorStyle(badge.color)]} numberOfLines={1}>
|
||||
{badge.label}
|
||||
@@ -92,13 +96,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
minWidth: HOST_BADGE_ICON_SIZE,
|
||||
// Outweighs a sibling's `flexShrink: 1` by enough that the badge is effectively fully
|
||||
// squeezed before that sibling gives up its first pixel. An equal factor would split the
|
||||
// loss in proportion to length, which hands the most space to the longest hostname —
|
||||
// exactly backwards. The icon has a fixed width, so the badge never disappears outright.
|
||||
flexShrink: 100,
|
||||
},
|
||||
icon: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
label: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -1042,6 +1042,16 @@ export const ar: TranslationResources = {
|
||||
updateHost: "حدّث هذا المضيف لإدارة التسميات.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "ما الجديد",
|
||||
installed: "مثبّت",
|
||||
showMore: "عرض المزيد",
|
||||
openWebsite: "سجل التغييرات الكامل",
|
||||
error: {
|
||||
title: "تعذّر تحميل سجل التغييرات",
|
||||
description: "تحقق من اتصالك وحاول مرة أخرى.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "تفضيلات العرض",
|
||||
@@ -2067,6 +2077,7 @@ export const ar: TranslationResources = {
|
||||
about: {
|
||||
title: "عن",
|
||||
appVersion: "نسخة التطبيق",
|
||||
whatsNewHint: "ملاحظات الإصدار لكل نسخة",
|
||||
thisDevice: "هذا الجهاز",
|
||||
connectedHosts: "المضيفين المتصلين",
|
||||
offline: "غير متصل",
|
||||
|
||||
@@ -1051,6 +1051,16 @@ export const en = {
|
||||
updateHost: "Update this host to manage labels.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "What's new",
|
||||
installed: "Installed",
|
||||
showMore: "Show more",
|
||||
openWebsite: "Full changelog",
|
||||
error: {
|
||||
title: "Unable to load the changelog",
|
||||
description: "Check your connection and try again.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Display preferences",
|
||||
@@ -2172,6 +2182,7 @@ export const en = {
|
||||
about: {
|
||||
title: "About",
|
||||
appVersion: "App version",
|
||||
whatsNewHint: "Release notes for every version",
|
||||
thisDevice: "This device",
|
||||
connectedHosts: "Connected hosts",
|
||||
offline: "Offline",
|
||||
|
||||
@@ -1076,6 +1076,16 @@ export const es: TranslationResources = {
|
||||
updateHost: "Actualiza este host para gestionar etiquetas.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Novedades",
|
||||
installed: "Instalada",
|
||||
showMore: "Mostrar más",
|
||||
openWebsite: "Registro de cambios completo",
|
||||
error: {
|
||||
title: "No se pudo cargar el registro de cambios",
|
||||
description: "Comprueba tu conexión e inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Preferencias de visualización",
|
||||
@@ -2117,6 +2127,7 @@ export const es: TranslationResources = {
|
||||
about: {
|
||||
title: "Acerca de",
|
||||
appVersion: "Versión de la aplicación",
|
||||
whatsNewHint: "Notas de versión de cada release",
|
||||
thisDevice: "este dispositivo",
|
||||
connectedHosts: "Anfitriones conectados",
|
||||
offline: "Desconectado",
|
||||
|
||||
@@ -1075,6 +1075,16 @@ export const fr: TranslationResources = {
|
||||
updateHost: "Mettez à jour cet hôte pour gérer les étiquettes.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Nouveautés",
|
||||
installed: "Installée",
|
||||
showMore: "Afficher plus",
|
||||
openWebsite: "Journal des modifications complet",
|
||||
error: {
|
||||
title: "Impossible de charger le journal des modifications",
|
||||
description: "Vérifiez votre connexion et réessayez.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Préférences d'affichage",
|
||||
@@ -2120,6 +2130,7 @@ export const fr: TranslationResources = {
|
||||
about: {
|
||||
title: "À propos",
|
||||
appVersion: "Version de l'application",
|
||||
whatsNewHint: "Notes de version pour chaque release",
|
||||
thisDevice: "Cet appareil",
|
||||
connectedHosts: "Hôtes connectés",
|
||||
offline: "Hors ligne",
|
||||
|
||||
@@ -1053,6 +1053,16 @@ export const ja: TranslationResources = {
|
||||
updateHost: "ラベルを管理するにはホストを更新してください。",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "新着情報",
|
||||
installed: "インストール済み",
|
||||
showMore: "もっと見る",
|
||||
openWebsite: "変更履歴をすべて表示",
|
||||
error: {
|
||||
title: "変更履歴を読み込めません",
|
||||
description: "接続を確認してもう一度お試しください。",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "表示設定",
|
||||
@@ -2084,6 +2094,7 @@ export const ja: TranslationResources = {
|
||||
about: {
|
||||
title: "アプリ情報",
|
||||
appVersion: "アプリバージョン",
|
||||
whatsNewHint: "各バージョンのリリースノート",
|
||||
thisDevice: "このデバイス",
|
||||
connectedHosts: "接続されているホスト",
|
||||
offline: "オフライン",
|
||||
|
||||
@@ -1049,6 +1049,16 @@ export const ko: TranslationResources = {
|
||||
updateHost: "레이블을 관리하려면 호스트를 업데이트하세요.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "새로운 소식",
|
||||
installed: "설치됨",
|
||||
showMore: "더 보기",
|
||||
openWebsite: "전체 변경 내역",
|
||||
error: {
|
||||
title: "변경 내역을 불러오지 못했습니다",
|
||||
description: "연결을 확인한 후 다시 시도하세요.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "표시 설정",
|
||||
@@ -2078,6 +2088,7 @@ export const ko: TranslationResources = {
|
||||
about: {
|
||||
title: "정보",
|
||||
appVersion: "앱 버전",
|
||||
whatsNewHint: "모든 버전의 릴리스 노트",
|
||||
thisDevice: "이 기기",
|
||||
connectedHosts: "연결된 호스트",
|
||||
offline: "오프라인",
|
||||
|
||||
@@ -1067,6 +1067,16 @@ export const ptBR: TranslationResources = {
|
||||
updateHost: "Atualize este host para gerenciar etiquetas.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Novidades",
|
||||
installed: "Instalada",
|
||||
showMore: "Mostrar mais",
|
||||
openWebsite: "Changelog completo",
|
||||
error: {
|
||||
title: "Não foi possível carregar o changelog",
|
||||
description: "Verifique sua conexão e tente novamente.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Preferências de exibição",
|
||||
@@ -2101,6 +2111,7 @@ export const ptBR: TranslationResources = {
|
||||
about: {
|
||||
title: "Sobre",
|
||||
appVersion: "Versão do app",
|
||||
whatsNewHint: "Notas de versão de cada release",
|
||||
thisDevice: "Este dispositivo",
|
||||
connectedHosts: "Hosts conectados",
|
||||
offline: "Offline",
|
||||
|
||||
@@ -1057,6 +1057,16 @@ export const ru: TranslationResources = {
|
||||
updateHost: "Обновите этот хост для управления метками.",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "Что нового",
|
||||
installed: "Установлена",
|
||||
showMore: "Показать ещё",
|
||||
openWebsite: "Полный список изменений",
|
||||
error: {
|
||||
title: "Не удалось загрузить список изменений",
|
||||
description: "Проверьте подключение и попробуйте снова.",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "Настройки отображения",
|
||||
@@ -2102,6 +2112,7 @@ export const ru: TranslationResources = {
|
||||
about: {
|
||||
title: "О приложении",
|
||||
appVersion: "Версия приложения",
|
||||
whatsNewHint: "Заметки о выпуске для каждой версии",
|
||||
thisDevice: "Это устройство",
|
||||
connectedHosts: "Подключенные хосты",
|
||||
offline: "Оффлайн",
|
||||
|
||||
@@ -1034,6 +1034,16 @@ export const zhCN: TranslationResources = {
|
||||
updateHost: "请更新此主机以管理标签。",
|
||||
},
|
||||
},
|
||||
changelog: {
|
||||
title: "新功能",
|
||||
installed: "已安装",
|
||||
showMore: "显示更多",
|
||||
openWebsite: "完整更新日志",
|
||||
error: {
|
||||
title: "无法加载更新日志",
|
||||
description: "请检查网络连接后重试。",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
display: {
|
||||
trigger: "显示偏好",
|
||||
@@ -2043,6 +2053,7 @@ export const zhCN: TranslationResources = {
|
||||
about: {
|
||||
title: "关于",
|
||||
appVersion: "应用版本",
|
||||
whatsNewHint: "每个版本的发布说明",
|
||||
thisDevice: "此设备",
|
||||
connectedHosts: "已连接的 Host",
|
||||
offline: "离线",
|
||||
|
||||
@@ -69,7 +69,7 @@ function useTerminalPanelDescriptor(
|
||||
label,
|
||||
subtitle: t("workspace.tabs.fallback.terminal"),
|
||||
tooltip: label,
|
||||
titleState: "ready",
|
||||
titleState: terminalsQuery.isPending ? "loading" : "ready",
|
||||
icon: Terminal,
|
||||
statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity),
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { pluginButtonStore } from "./store";
|
||||
@@ -0,0 +1,169 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PluginButton } from "@getpaseo/plugin/client";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { PluginButtonStore, buttonMatches } from "./model";
|
||||
|
||||
function installation(): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId: "host-a",
|
||||
clientBundle: "bundle",
|
||||
queryClient: new QueryClient(),
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
function store() {
|
||||
return new PluginButtonStore({
|
||||
validateIconName(name) {
|
||||
if (name !== "Scan") throw new Error("Unknown icon");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function button(onPress = () => {}): PluginButton {
|
||||
return { title: "Review", icon: "Scan", behavior: { kind: "action", onPress } };
|
||||
}
|
||||
|
||||
describe("plugin buttons", () => {
|
||||
it("updates visibility in place, closes hidden content, and scopes each placement", () => {
|
||||
const buttons = store();
|
||||
const plugin = installation();
|
||||
const header = buttons.addHeaderButton(plugin, {
|
||||
id: "review",
|
||||
workspaceId: "workspace",
|
||||
button: button(),
|
||||
});
|
||||
buttons.addComposerPill(plugin, {
|
||||
id: "review",
|
||||
workspaceId: "workspace",
|
||||
agentId: "agent",
|
||||
button: button(),
|
||||
});
|
||||
const first = buttons.getSnapshot()[0];
|
||||
expect(buttonMatches(first, "host-a", "workspace", null)).toBe(true);
|
||||
expect(buttonMatches(first, "host-b", "workspace", null)).toBe(false);
|
||||
expect(buttonMatches(first, "host-a", "another-workspace", null)).toBe(false);
|
||||
expect(buttonMatches(first, "host-a", "workspace", "agent")).toBe(false);
|
||||
expect(buttonMatches(buttons.getSnapshot()[1], "host-a", "workspace", "agent")).toBe(true);
|
||||
expect(buttonMatches(buttons.getSnapshot()[1], "host-a", "workspace", "other-agent")).toBe(
|
||||
false,
|
||||
);
|
||||
buttons.setOpen(first.key, true);
|
||||
header.update({ visible: false, label: "3 reviews" });
|
||||
expect(buttonMatches(buttons.getSnapshot()[0], "host-a", "workspace", null)).toBe(false);
|
||||
expect(buttons.getSnapshot()[0].open).toBe(false);
|
||||
header.update({ visible: true });
|
||||
expect(buttons.getSnapshot().map((entry) => entry.key)).toEqual([first.key, first.key + 1]);
|
||||
expect(buttons.getSnapshot()[0].button.label).toBe("3 reviews");
|
||||
header.remove();
|
||||
header.remove();
|
||||
header.update({ visible: true });
|
||||
expect(buttons.getSnapshot().map((entry) => entry.placement)).toEqual(["composer"]);
|
||||
});
|
||||
|
||||
it("rejects duplicates within a target and validates updates atomically", () => {
|
||||
const buttons = store();
|
||||
const plugin = installation();
|
||||
const contribution = { id: "review", workspaceId: "workspace", button: button() };
|
||||
const header = buttons.addHeaderButton(plugin, contribution);
|
||||
expect(() => buttons.addHeaderButton(plugin, contribution)).toThrow(
|
||||
"Duplicate plugin button: review",
|
||||
);
|
||||
expect(() => buttons.addHeaderButton(installation(), contribution)).not.toThrow();
|
||||
expect(() =>
|
||||
buttons.addHeaderButton(plugin, { ...contribution, workspaceId: "other" }),
|
||||
).not.toThrow();
|
||||
expect(() => header.update({ title: "Changed", icon: "Invalid" })).toThrow("Unknown icon");
|
||||
expect(buttons.getSnapshot()[0].button.title).toBe("Review");
|
||||
});
|
||||
|
||||
it("prevents repeated presses and cannot resurrect a removed pending button", async () => {
|
||||
const buttons = store();
|
||||
let finish = () => {};
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let calls = 0;
|
||||
const registration = buttons.addHeaderButton(installation(), {
|
||||
id: "review",
|
||||
workspaceId: "workspace",
|
||||
button: button(() => {
|
||||
calls++;
|
||||
return pending;
|
||||
}),
|
||||
});
|
||||
const key = buttons.getSnapshot()[0].key;
|
||||
const action = buttons.run(key);
|
||||
await buttons.run(key);
|
||||
expect(calls).toBe(1);
|
||||
expect(buttons.getSnapshot()[0].pending).toBe(true);
|
||||
registration.remove();
|
||||
finish();
|
||||
await action;
|
||||
expect(buttons.getSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it("allows retry after failure and respects disabled actions and menu ancestors", async () => {
|
||||
const buttons = store();
|
||||
let calls = 0;
|
||||
const registration = buttons.addHeaderButton(installation(), {
|
||||
id: "review",
|
||||
workspaceId: "workspace",
|
||||
button: button(() => {
|
||||
if (++calls === 1) throw new Error("Review unavailable");
|
||||
}),
|
||||
});
|
||||
const key = buttons.getSnapshot()[0].key;
|
||||
await expect(buttons.run(key)).rejects.toThrow("Review unavailable");
|
||||
expect(buttons.getSnapshot()[0].pending).toBe(false);
|
||||
await buttons.run(key);
|
||||
expect(calls).toBe(2);
|
||||
registration.update({ disabled: true });
|
||||
await buttons.run(key);
|
||||
expect(calls).toBe(2);
|
||||
registration.update({
|
||||
disabled: false,
|
||||
behavior: {
|
||||
kind: "menu",
|
||||
items: [
|
||||
{
|
||||
kind: "item",
|
||||
id: "nested",
|
||||
title: "Nested",
|
||||
disabled: true,
|
||||
behavior: {
|
||||
kind: "menu",
|
||||
items: [
|
||||
{
|
||||
kind: "item",
|
||||
id: "run",
|
||||
title: "Run",
|
||||
behavior: {
|
||||
kind: "action",
|
||||
onPress() {
|
||||
calls++;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await buttons.run(key, ["nested", "run"]);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
PluginButtonBehavior,
|
||||
PluginButtonContext,
|
||||
PluginButtonRegistration,
|
||||
PluginHeaderButtonContribution,
|
||||
PluginComposerPillContribution,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import {
|
||||
requireButtonId,
|
||||
validateButton,
|
||||
type ButtonValidation,
|
||||
type ResolvedPluginButton,
|
||||
} from "./validation";
|
||||
|
||||
export type ButtonPlacement = "header" | "composer";
|
||||
|
||||
export interface RegisteredPluginButton {
|
||||
key: number;
|
||||
id: string;
|
||||
installation: InstalledPlugin;
|
||||
placement: ButtonPlacement;
|
||||
context: PluginButtonContext;
|
||||
button: ResolvedPluginButton;
|
||||
pending: boolean;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
function resolveAction(
|
||||
behavior: PluginButtonBehavior,
|
||||
path: readonly string[],
|
||||
): (() => void | Promise<void>) | null {
|
||||
if (path.length === 0) return behavior.kind === "action" ? behavior.onPress : null;
|
||||
if (behavior.kind !== "menu") return null;
|
||||
const item = behavior.items.find((entry) => entry.id === path[0]);
|
||||
if (!item || item.kind !== "item" || item.visible === false || item.disabled) return null;
|
||||
return resolveAction(item.behavior, path.slice(1));
|
||||
}
|
||||
|
||||
export class PluginButtonStore {
|
||||
private entries: readonly RegisteredPluginButton[] = [];
|
||||
private nextKey = 0;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
constructor(private validation: ButtonValidation) {}
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
getSnapshot = (): readonly RegisteredPluginButton[] => this.entries;
|
||||
|
||||
addHeaderButton(
|
||||
installation: InstalledPlugin,
|
||||
input: PluginHeaderButtonContribution,
|
||||
): PluginButtonRegistration {
|
||||
return this.add(installation, input, "header", {
|
||||
context: "workspace",
|
||||
workspaceId: input.workspaceId.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
addComposerPill(
|
||||
installation: InstalledPlugin,
|
||||
input: PluginComposerPillContribution,
|
||||
): PluginButtonRegistration {
|
||||
if (!input.agentId.trim()) throw new Error("Plugin composer pill needs an agent");
|
||||
return this.add(installation, input, "composer", {
|
||||
context: "agent",
|
||||
workspaceId: input.workspaceId.trim(),
|
||||
agentId: input.agentId.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
private add(
|
||||
installation: InstalledPlugin,
|
||||
input: PluginHeaderButtonContribution,
|
||||
placement: ButtonPlacement,
|
||||
context: PluginButtonContext,
|
||||
): PluginButtonRegistration {
|
||||
const id = requireButtonId(input.id);
|
||||
if (!context.workspaceId.trim()) throw new Error("Plugin button needs a workspace");
|
||||
const duplicate = this.entries.some(
|
||||
(entry) =>
|
||||
entry.installation === installation &&
|
||||
entry.placement === placement &&
|
||||
entry.id === id &&
|
||||
entry.context.workspaceId === context.workspaceId &&
|
||||
(entry.context.context === "workspace" ||
|
||||
(context.context === "agent" && entry.context.agentId === context.agentId)),
|
||||
);
|
||||
if (duplicate) throw new Error(`Duplicate plugin button: ${id}`);
|
||||
const key = this.nextKey++;
|
||||
const initialButton = validateButton(input.button, this.validation);
|
||||
this.publish([
|
||||
...this.entries,
|
||||
{
|
||||
key,
|
||||
id,
|
||||
installation,
|
||||
placement,
|
||||
context,
|
||||
button: initialButton,
|
||||
pending: false,
|
||||
open: false,
|
||||
},
|
||||
]);
|
||||
return {
|
||||
update: (patch) => {
|
||||
const entry = this.entries.find((candidate) => candidate.key === key);
|
||||
if (!entry) return;
|
||||
const button = validateButton({ ...entry.button, ...patch }, this.validation);
|
||||
const close = !button.visible || button.disabled || patch.behavior !== undefined;
|
||||
this.replace(key, { button, open: close ? false : entry.open });
|
||||
},
|
||||
remove: () => {
|
||||
const next = this.entries.filter((entry) => entry.key !== key);
|
||||
if (next.length !== this.entries.length) this.publish(next);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
setOpen(key: number, open: boolean): void {
|
||||
const entry = this.entries.find((candidate) => candidate.key === key);
|
||||
if (!entry) return;
|
||||
if (open && (!entry.button.visible || entry.button.disabled || entry.pending)) return;
|
||||
this.replace(key, { open });
|
||||
}
|
||||
|
||||
async run(key: number, path: readonly string[] = []): Promise<void> {
|
||||
const entry = this.entries.find((candidate) => candidate.key === key);
|
||||
if (!entry || !entry.button.visible || entry.button.disabled || entry.pending) return;
|
||||
const action = resolveAction(entry.button.behavior, path);
|
||||
if (!action) return;
|
||||
this.replace(key, { pending: true });
|
||||
try {
|
||||
await action();
|
||||
} finally {
|
||||
this.replace(key, { pending: false });
|
||||
}
|
||||
}
|
||||
|
||||
private replace(
|
||||
key: number,
|
||||
patch: Partial<Pick<RegisteredPluginButton, "button" | "open" | "pending">>,
|
||||
): void {
|
||||
if (!this.entries.some((entry) => entry.key === key)) return;
|
||||
this.publish(this.entries.map((entry) => (entry.key === key ? { ...entry, ...patch } : entry)));
|
||||
}
|
||||
|
||||
private publish(entries: readonly RegisteredPluginButton[]): void {
|
||||
this.entries = entries;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
export function buttonMatches(
|
||||
entry: RegisteredPluginButton,
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string | null,
|
||||
): boolean {
|
||||
if (
|
||||
entry.installation.serverId !== serverId ||
|
||||
entry.context.workspaceId !== workspaceId ||
|
||||
!entry.button.visible
|
||||
)
|
||||
return false;
|
||||
if (agentId === null) return entry.placement === "header";
|
||||
return (
|
||||
entry.placement === "composer" &&
|
||||
entry.context.context === "agent" &&
|
||||
entry.context.agentId === agentId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { PluginButtonStore } from "./model";
|
||||
|
||||
export const pluginButtonStore = new PluginButtonStore({ validateIconName: resolvePluginIcon });
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PluginButton, PluginButtonBehavior, PluginButtonIcon } from "@getpaseo/plugin/client";
|
||||
|
||||
export interface ButtonValidation {
|
||||
validateIconName(name: string): void;
|
||||
}
|
||||
|
||||
export function requireButtonId(value: string): string {
|
||||
if (typeof value !== "string" || !/^[a-z][a-z0-9-]*$/.test(value))
|
||||
throw new Error(`Invalid plugin button id: ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: boolean | undefined, fallback: boolean): boolean {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "boolean")
|
||||
throw new Error("Plugin button visibility and disabled state must be booleans");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireText(value: string, field: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new Error(`Plugin button needs ${field}`);
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function isComponent(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "function" ||
|
||||
(typeof value === "object" && value !== null && "$$typeof" in value)
|
||||
);
|
||||
}
|
||||
|
||||
function validateIcon(icon: PluginButtonIcon, validation: ButtonValidation): void {
|
||||
if (typeof icon === "string") validation.validateIconName(icon);
|
||||
else if (!isComponent(icon))
|
||||
throw new Error("Plugin button icon must be a Lucide name or component");
|
||||
}
|
||||
|
||||
function validateBehavior(
|
||||
behavior: PluginButtonBehavior,
|
||||
validation: ButtonValidation,
|
||||
): PluginButtonBehavior {
|
||||
switch (behavior.kind) {
|
||||
case "action":
|
||||
if (typeof behavior.onPress !== "function")
|
||||
throw new Error("Plugin button action needs onPress");
|
||||
return { ...behavior };
|
||||
case "popover":
|
||||
if (!isComponent(behavior.Content)) throw new Error("Plugin button popover needs Content");
|
||||
return { ...behavior };
|
||||
case "menu": {
|
||||
const ids = new Set<string>();
|
||||
return {
|
||||
kind: "menu",
|
||||
items: behavior.items.map((item) => {
|
||||
requireButtonId(item.id);
|
||||
if (ids.has(item.id)) throw new Error(`Duplicate plugin button menu item: ${item.id}`);
|
||||
ids.add(item.id);
|
||||
if (item.kind === "separator") return { ...item };
|
||||
if (item.kind !== "item") throw new Error("Invalid plugin button menu entry");
|
||||
if (item.icon !== undefined) validateIcon(item.icon, validation);
|
||||
return {
|
||||
...item,
|
||||
title: requireText(item.title, "menu item title"),
|
||||
visible: optionalBoolean(item.visible, true),
|
||||
disabled: optionalBoolean(item.disabled, false),
|
||||
behavior: validateBehavior(item.behavior, validation),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid plugin button behavior");
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolvedPluginButton = PluginButton & {
|
||||
visible: boolean;
|
||||
disabled: boolean;
|
||||
label: string | undefined;
|
||||
};
|
||||
|
||||
export function validateButton(
|
||||
button: PluginButton,
|
||||
validation: ButtonValidation,
|
||||
): ResolvedPluginButton {
|
||||
validateIcon(button.icon, validation);
|
||||
return {
|
||||
...button,
|
||||
title: requireText(button.title, "title"),
|
||||
label: button.label === undefined ? undefined : requireText(button.label, "label"),
|
||||
visible: optionalBoolean(button.visible, true),
|
||||
disabled: optionalBoolean(button.disabled, false),
|
||||
behavior: validateBehavior(button.behavior, validation),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import type {
|
||||
PluginButtonBehavior,
|
||||
PluginButtonIcon,
|
||||
PluginButtonMenuEntry,
|
||||
PluginHostProps,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
|
||||
import { Platform, Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MenuRoot,
|
||||
MenuTrigger,
|
||||
MenuSurface,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuSubTrigger,
|
||||
useMenuContext,
|
||||
type MenuPageDefinition,
|
||||
} from "@/components/ui/menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
import {
|
||||
iconButtonChromeStyle,
|
||||
type IconButtonChromeState,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { Icon } from "../icons";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import { createPluginSurfaceRuntime, type PluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { buttonMatches, type RegisteredPluginButton } from "./model";
|
||||
import { pluginButtonStore } from "./store";
|
||||
|
||||
interface ButtonView {
|
||||
entry: RegisteredPluginButton;
|
||||
props: PluginHostProps & RegisteredPluginButton["context"];
|
||||
runtime: PluginSurfaceRuntime;
|
||||
state: ReturnType<typeof createPluginClientStateSource>;
|
||||
toast: ReturnType<typeof useToast>;
|
||||
}
|
||||
|
||||
const ROOT_PATH: readonly string[] = [];
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const errorIconMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
const ThemedMoreIcon = withUnistyles(MoreHorizontal);
|
||||
const ThemedErrorIcon = withUnistyles(AlertCircle);
|
||||
|
||||
function headerButtonStyle(compact: boolean, state: IconButtonChromeState, disabled = false) {
|
||||
if (compact) return iconButtonChromeStyle({ size: "large", state, disabled });
|
||||
return [
|
||||
styles.headerButton,
|
||||
styles.button,
|
||||
(state.hovered || state.pressed || state.open) && styles.active,
|
||||
disabled && styles.disabled,
|
||||
];
|
||||
}
|
||||
|
||||
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
// These providers live inside the surface content as well as around its trigger. Native sheets
|
||||
// teleport their children, so providers around MenuRoot alone cannot reach the plugin body.
|
||||
function ButtonEnvironment({ view, children }: { view: ButtonView; children: ReactNode }) {
|
||||
return (
|
||||
<ToastApiProvider api={view.toast}>
|
||||
<PluginRuntimeBoundary plugin={view.entry.installation} runtime={view.runtime}>
|
||||
<PluginClientStateProvider source={view.state}>{children}</PluginClientStateProvider>
|
||||
</PluginRuntimeBoundary>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonIcon({ view, icon }: { view: ButtonView; icon: PluginButtonIcon }) {
|
||||
const color = view.props.theme.colors.foregroundMuted;
|
||||
const size = view.entry.placement === "composer" ? 14 : 16;
|
||||
return (
|
||||
<View
|
||||
style={styles.icon}
|
||||
pointerEvents="none"
|
||||
accessible={false}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
{typeof icon === "string" ? (
|
||||
<Icon name={icon} size={size} color={color} />
|
||||
) : (
|
||||
renderCustomIcon(icon, view, size, color)
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function renderCustomIcon(
|
||||
CustomIcon: Exclude<PluginButtonIcon, string>,
|
||||
view: ButtonView,
|
||||
size: number,
|
||||
color: string,
|
||||
) {
|
||||
return <CustomIcon {...view.props} size={size} color={color} />;
|
||||
}
|
||||
|
||||
function pressButton(view: ButtonView, path: readonly string[]) {
|
||||
void pluginButtonStore.run(view.entry.key, path).catch((error: unknown) => {
|
||||
view.toast.error(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
function visibleMenuItems(
|
||||
items: readonly PluginButtonMenuEntry[],
|
||||
): readonly PluginButtonMenuEntry[] {
|
||||
const visible: PluginButtonMenuEntry[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "item" && item.visible === false) continue;
|
||||
if (item.kind === "separator" && (visible.length === 0 || visible.at(-1)?.kind === "separator"))
|
||||
continue;
|
||||
visible.push(item);
|
||||
}
|
||||
if (visible.at(-1)?.kind === "separator") visible.pop();
|
||||
return visible;
|
||||
}
|
||||
|
||||
function ButtonMenuItem({
|
||||
view,
|
||||
title,
|
||||
icon,
|
||||
behavior,
|
||||
disabled,
|
||||
path,
|
||||
pageId,
|
||||
}: {
|
||||
view: ButtonView;
|
||||
title: string;
|
||||
icon?: PluginButtonIcon;
|
||||
behavior: PluginButtonBehavior;
|
||||
disabled: boolean;
|
||||
path: readonly string[];
|
||||
pageId: string;
|
||||
}) {
|
||||
const select = useCallback(() => pressButton(view, path), [view, path]);
|
||||
const leading = useMemo(
|
||||
() => (icon ? <ButtonIcon view={view} icon={icon} /> : null),
|
||||
[icon, view],
|
||||
);
|
||||
if (behavior.kind !== "action") {
|
||||
return (
|
||||
<MenuSubTrigger id={pageId} leading={leading} disabled={disabled}>
|
||||
{title}
|
||||
</MenuSubTrigger>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MenuItem onSelect={select} leading={leading} disabled={disabled}>
|
||||
{title}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonBody({
|
||||
view,
|
||||
behavior,
|
||||
path,
|
||||
}: {
|
||||
view: ButtonView;
|
||||
behavior: PluginButtonBehavior;
|
||||
path: readonly string[];
|
||||
}) {
|
||||
const menu = useMenuContext("PluginButton");
|
||||
const close = useCallback(() => menu.setOpen(false), [menu]);
|
||||
if (behavior.kind === "popover") {
|
||||
const Content = behavior.Content;
|
||||
return (
|
||||
<View style={styles.content}>
|
||||
<Content {...view.props} close={close} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (behavior.kind !== "menu") return null;
|
||||
return visibleMenuItems(behavior.items).map((item) => {
|
||||
if (item.kind === "separator") return <MenuSeparator key={item.id} />;
|
||||
const itemPath = [...path, item.id];
|
||||
return (
|
||||
<ButtonMenuItem
|
||||
key={item.id}
|
||||
view={view}
|
||||
title={item.title}
|
||||
icon={item.icon}
|
||||
behavior={item.behavior}
|
||||
disabled={view.entry.pending || view.entry.button.disabled || item.disabled === true}
|
||||
path={itemPath}
|
||||
pageId={buttonPageId(view, itemPath)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function ButtonSurfaceBody({
|
||||
view,
|
||||
behavior,
|
||||
path,
|
||||
}: {
|
||||
view: ButtonView;
|
||||
behavior: PluginButtonBehavior;
|
||||
path: readonly string[];
|
||||
}) {
|
||||
return (
|
||||
<ButtonEnvironment view={view}>
|
||||
<SurfaceErrorBoundary installation={view.entry.installation} Surface={behavior}>
|
||||
<ButtonBody view={view} behavior={behavior} path={path} />
|
||||
</SurfaceErrorBoundary>
|
||||
</ButtonEnvironment>
|
||||
);
|
||||
}
|
||||
|
||||
function buttonPageId(view: ButtonView, path: readonly string[]): string {
|
||||
return [view.entry.key, ...path].join("/");
|
||||
}
|
||||
|
||||
function buttonPages(
|
||||
view: ButtonView,
|
||||
behavior: PluginButtonBehavior,
|
||||
path: readonly string[] = [],
|
||||
): MenuPageDefinition[] {
|
||||
if (behavior.kind !== "menu") return [];
|
||||
return behavior.items.flatMap((item) => {
|
||||
if (item.kind === "separator" || item.visible === false || item.behavior.kind === "action")
|
||||
return [];
|
||||
const itemPath = [...path, item.id];
|
||||
return [
|
||||
{
|
||||
id: buttonPageId(view, itemPath),
|
||||
title: item.title,
|
||||
hoverIntent: item.behavior.kind === "menu",
|
||||
content: <ButtonSurfaceBody view={view} behavior={item.behavior} path={itemPath} />,
|
||||
},
|
||||
...buttonPages(view, item.behavior, itemPath),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function ButtonControl({ view }: { view: ButtonView }) {
|
||||
const { entry, props } = view;
|
||||
const { button } = entry;
|
||||
const composer = entry.placement === "composer";
|
||||
const disabled = button.disabled || entry.pending;
|
||||
const expanded = button.behavior.kind !== "action";
|
||||
const chevron = !composer && !props.layout.compact && expanded;
|
||||
let label = button.label;
|
||||
if (composer) label = button.label ?? button.title;
|
||||
else if (props.layout.compact) label = undefined;
|
||||
const press = useCallback(() => pressButton(view, []), [view]);
|
||||
const setOpen = useCallback(
|
||||
(open: boolean) => pluginButtonStore.setOpen(entry.key, open),
|
||||
[entry.key],
|
||||
);
|
||||
const buttonStyle = useCallback(
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
|
||||
composer
|
||||
? [
|
||||
composerPillStyles.body,
|
||||
styles.button,
|
||||
(hovered || pressed || entry.open) && styles.active,
|
||||
disabled && styles.disabled,
|
||||
]
|
||||
: headerButtonStyle(props.layout.compact, { hovered, pressed, open: entry.open }, disabled),
|
||||
[composer, disabled, entry.open, props.layout.compact],
|
||||
);
|
||||
const contents = (
|
||||
<>
|
||||
{entry.pending ? (
|
||||
<View style={styles.icon}>
|
||||
<LoadingSpinner size={14} color={props.theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : (
|
||||
<ButtonIcon view={view} icon={button.icon} />
|
||||
)}
|
||||
{label ? (
|
||||
<Text numberOfLines={1} style={composer ? styles.composerLabel : styles.label}>
|
||||
{label}
|
||||
</Text>
|
||||
) : null}
|
||||
{chevron ? (
|
||||
<View testID="plugin-button-chevron">
|
||||
<ChevronDown size={12} color={props.theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ busy: entry.pending, disabled }),
|
||||
[entry.pending, disabled],
|
||||
);
|
||||
const trigger = expanded ? (
|
||||
<MenuTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={button.title}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{contents}
|
||||
</MenuTrigger>
|
||||
) : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={button.title}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={disabled}
|
||||
onPress={press}
|
||||
style={buttonStyle}
|
||||
>
|
||||
{contents}
|
||||
</Pressable>
|
||||
);
|
||||
const pages = useMemo(() => buttonPages(view, button.behavior), [view, button.behavior]);
|
||||
return (
|
||||
<MenuRoot compactMode="sheet" open={entry.open} onOpenChange={setOpen}>
|
||||
<Tooltip enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Text style={styles.tooltipLabel}>{button.title}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{expanded ? (
|
||||
<MenuSurface
|
||||
sheetTitle={button.title}
|
||||
side={composer ? "top" : "bottom"}
|
||||
align={composer ? "start" : "end"}
|
||||
offset={composer ? 12 : 4}
|
||||
minWidth={280}
|
||||
maxWidth={420}
|
||||
maxHeight={440}
|
||||
scrollable
|
||||
pages={pages}
|
||||
>
|
||||
<ButtonSurfaceBody view={view} behavior={button.behavior} path={ROOT_PATH} />
|
||||
</MenuSurface>
|
||||
) : null}
|
||||
</MenuRoot>
|
||||
);
|
||||
}
|
||||
|
||||
function BrokenButton({
|
||||
title,
|
||||
error,
|
||||
compact,
|
||||
}: {
|
||||
title: string;
|
||||
error: string;
|
||||
compact: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={title}
|
||||
disabled
|
||||
style={headerButtonStyle(compact, {}, true)}
|
||||
>
|
||||
<ThemedErrorIcon size={16} uniProps={errorIconMapping} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Text style={styles.tooltipLabel}>{error}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function createButtonView({
|
||||
entry,
|
||||
client,
|
||||
toast,
|
||||
compact,
|
||||
hostLabel,
|
||||
theme,
|
||||
}: {
|
||||
entry: RegisteredPluginButton;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
toast: ReturnType<typeof useToast>;
|
||||
compact: boolean;
|
||||
hostLabel: string;
|
||||
theme: PluginTheme;
|
||||
}): ButtonView | null {
|
||||
const runtime = createPluginSurfaceRuntime(client, entry.installation.id);
|
||||
if (!runtime) return null;
|
||||
return {
|
||||
entry,
|
||||
runtime,
|
||||
toast,
|
||||
state: createPluginClientStateSource(entry.installation.serverId),
|
||||
props: {
|
||||
...entry.context,
|
||||
theme,
|
||||
host: { id: entry.installation.serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePlatform() },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function PluginButtonHost({
|
||||
entry,
|
||||
compact,
|
||||
hostLabel,
|
||||
theme,
|
||||
}: {
|
||||
entry: RegisteredPluginButton;
|
||||
compact: boolean;
|
||||
hostLabel: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const client = useHostRuntimeClient(entry.installation.serverId);
|
||||
const toast = useToast();
|
||||
const view = useMemo(
|
||||
() => createButtonView({ entry, client, toast, compact, hostLabel, theme }),
|
||||
[entry, client, toast, compact, hostLabel, theme],
|
||||
);
|
||||
const renderError = useCallback(
|
||||
(error: string) => <BrokenButton title={entry.button.title} error={error} compact={compact} />,
|
||||
[entry.button.title, compact],
|
||||
);
|
||||
if (!view) return null;
|
||||
return (
|
||||
<SurfaceErrorBoundary
|
||||
installation={entry.installation}
|
||||
Surface={entry.button.icon}
|
||||
renderError={renderError}
|
||||
>
|
||||
<ButtonEnvironment view={view}>
|
||||
<ButtonControl view={view} />
|
||||
</ButtonEnvironment>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginButton = withUnistyles(PluginButtonHost);
|
||||
|
||||
// Overflow rows keep their own plugin runtime; a menu can contain several installations.
|
||||
function OverflowButton({ view }: { view: ButtonView }) {
|
||||
const { button } = view.entry;
|
||||
return (
|
||||
<ButtonMenuItem
|
||||
view={view}
|
||||
title={button.title}
|
||||
icon={button.icon}
|
||||
behavior={button.behavior}
|
||||
disabled={button.disabled || view.entry.pending}
|
||||
path={ROOT_PATH}
|
||||
pageId={buttonPageId(view, [])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OverflowPages({
|
||||
entries,
|
||||
compact,
|
||||
hostLabel,
|
||||
theme,
|
||||
}: {
|
||||
entries: readonly RegisteredPluginButton[];
|
||||
compact: boolean;
|
||||
hostLabel: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
// The header belongs to one host, but each button keeps its installation's query cache and RPCs.
|
||||
const client = useHostRuntimeClient(entries[0].installation.serverId);
|
||||
const toast = useToast();
|
||||
const menuContent = useMemo(() => {
|
||||
const pages: MenuPageDefinition[] = [];
|
||||
const rows: ReactNode[] = [];
|
||||
for (const entry of entries) {
|
||||
const view = createButtonView({ entry, client, toast, compact, hostLabel, theme });
|
||||
if (!view) continue;
|
||||
rows.push(
|
||||
<ButtonEnvironment key={entry.key} view={view}>
|
||||
<SurfaceErrorBoundary installation={entry.installation} Surface={entry.button.icon}>
|
||||
<OverflowButton view={view} />
|
||||
</SurfaceErrorBoundary>
|
||||
</ButtonEnvironment>,
|
||||
);
|
||||
if (entry.button.behavior.kind === "action") continue;
|
||||
pages.push(
|
||||
{
|
||||
id: buttonPageId(view, []),
|
||||
title: entry.button.title,
|
||||
hoverIntent: false,
|
||||
content: (
|
||||
<ButtonSurfaceBody view={view} behavior={entry.button.behavior} path={ROOT_PATH} />
|
||||
),
|
||||
},
|
||||
...buttonPages(view, entry.button.behavior),
|
||||
);
|
||||
}
|
||||
return { pages, rows };
|
||||
}, [entries, client, toast, theme, hostLabel, compact]);
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<MenuSurface
|
||||
sheetTitle={t("workspace.git.actions.moreActions")}
|
||||
align="end"
|
||||
minWidth={280}
|
||||
maxWidth={420}
|
||||
maxHeight={440}
|
||||
scrollable
|
||||
pages={menuContent.pages}
|
||||
>
|
||||
{menuContent.rows}
|
||||
</MenuSurface>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedOverflowPages = withUnistyles(OverflowPages);
|
||||
|
||||
function useButtons(serverId: string, workspaceId: string, agentId: string | null) {
|
||||
const entries = useSyncExternalStore(
|
||||
pluginButtonStore.subscribe,
|
||||
pluginButtonStore.getSnapshot,
|
||||
pluginButtonStore.getSnapshot,
|
||||
);
|
||||
return useMemo(
|
||||
() => entries.filter((entry) => buttonMatches(entry, serverId, workspaceId, agentId)),
|
||||
[entries, serverId, workspaceId, agentId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useHasPluginComposerPills(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
return useButtons(serverId, workspaceId, agentId).length > 0;
|
||||
}
|
||||
|
||||
export function PluginComposerPills({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
compact,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const entries = useButtons(serverId, workspaceId, agentId);
|
||||
const hosts = useHosts();
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
return entries.map((entry) => (
|
||||
<ThemedPluginButton
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export function PluginHeaderButtons({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const entries = useButtons(serverId, workspaceId, null);
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { width } = useWindowDimensions();
|
||||
const hosts = useHosts();
|
||||
const { t } = useTranslation();
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const limit = compact || width < 1100 ? 1 : 3;
|
||||
const visible = entries.slice(0, limit);
|
||||
const overflow = entries.slice(limit);
|
||||
const overflowStyle = useCallback(
|
||||
(state: IconButtonChromeState) => headerButtonStyle(compact, state),
|
||||
[compact],
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<View
|
||||
accessibilityRole="toolbar"
|
||||
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
|
||||
style={styles.headerButtons}
|
||||
>
|
||||
{visible.map((entry) => (
|
||||
<ThemedPluginButton
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
))}
|
||||
{overflow.length > 0 ? (
|
||||
<MenuRoot compactMode="sheet">
|
||||
<MenuTrigger
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.git.actions.moreActions")}
|
||||
style={overflowStyle}
|
||||
>
|
||||
<ThemedMoreIcon size={16} uniProps={mutedIconMapping} />
|
||||
</MenuTrigger>
|
||||
<ThemedOverflowPages
|
||||
entries={overflow}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
</MenuRoot>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
headerButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: { xs: theme.spacing[1], md: theme.spacing[2] },
|
||||
},
|
||||
headerButton: {
|
||||
height: { xs: 32, md: HEADER_CONTROL_HEIGHT },
|
||||
minWidth: { xs: 32, md: HEADER_CONTROL_HEIGHT },
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
button: { flexShrink: 1, minWidth: 0, maxWidth: 160 },
|
||||
active: { backgroundColor: theme.colors.surface2 },
|
||||
disabled: { opacity: theme.opacity[50] },
|
||||
tooltipLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground },
|
||||
composerLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
label: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1 },
|
||||
icon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
content: { padding: theme.spacing[3], gap: theme.spacing[2] },
|
||||
}));
|
||||
@@ -1,90 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginComposerPillProps } from "@getpaseo/plugin/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
import { createPluginClientRuntime } from "./client-runtime";
|
||||
import { pluginComposerPillStore } from "./composer-pills/store";
|
||||
|
||||
function Pill(_props: PluginComposerPillProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function installation(): InstalledPlugin {
|
||||
return {
|
||||
id: "review",
|
||||
serverId: "host-a",
|
||||
clientBundle: "bundle",
|
||||
queryClient: new QueryClient(),
|
||||
cleanup: () => undefined,
|
||||
settingsScreens: [],
|
||||
surfaces: [],
|
||||
sidebarItems: [],
|
||||
workspacePanels: [],
|
||||
commandCenterItems: [],
|
||||
clientSlashCommands: [],
|
||||
attachmentSources: [],
|
||||
themes: [],
|
||||
timelineTransformers: [],
|
||||
timelineRenderers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const daemonClient = {} as DaemonClient;
|
||||
const installations: InstalledPlugin[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const plugin of installations.splice(0)) {
|
||||
pluginComposerPillStore.removeInstallation(plugin);
|
||||
}
|
||||
});
|
||||
|
||||
describe("plugin client-side lifecycle", () => {
|
||||
it("lets the client runtime add and remove a targeted composer pill", () => {
|
||||
const plugin = installation();
|
||||
installations.push(plugin);
|
||||
const client = createPluginClientRuntime(plugin, daemonClient);
|
||||
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
const remove = client.addComposerPill({
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
});
|
||||
|
||||
expect(
|
||||
pluginComposerPillStore.getSnapshot().map(({ contribution }) => ({
|
||||
id: contribution.id,
|
||||
workspaceId: contribution.workspaceId,
|
||||
agentId: contribution.agentId,
|
||||
})),
|
||||
).toEqual([{ id: "review-ready", workspaceId: "workspace-a", agentId: "agent-a" }]);
|
||||
|
||||
remove();
|
||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate pills only within the same plugin target", () => {
|
||||
const plugin = installation();
|
||||
installations.push(plugin);
|
||||
const contribution = {
|
||||
id: "review-ready",
|
||||
title: "Open review",
|
||||
workspaceId: "workspace-a",
|
||||
agentId: "agent-a",
|
||||
Component: Pill,
|
||||
onPress() {},
|
||||
};
|
||||
|
||||
pluginComposerPillStore.add(plugin, contribution);
|
||||
expect(() => pluginComposerPillStore.add(plugin, contribution)).toThrow(
|
||||
"Duplicate composer pill: review-ready",
|
||||
);
|
||||
expect(() =>
|
||||
pluginComposerPillStore.add(plugin, { ...contribution, agentId: "agent-b" }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { createPluginClientStateSource } from "./client-state/source";
|
||||
import type { PluginClientRuntime } from "./evaluate";
|
||||
import { createPluginNavigation } from "./navigation";
|
||||
import { pluginComposerPillStore } from "./composer-pills/store";
|
||||
import { pluginButtonStore } from "./buttons";
|
||||
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
@@ -27,7 +27,10 @@ export function createPluginClientRuntime(
|
||||
return {
|
||||
...capabilities,
|
||||
addComposerPill(contribution) {
|
||||
return pluginComposerPillStore.add(installation, contribution);
|
||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||
},
|
||||
addHeaderButton(contribution) {
|
||||
return pluginButtonStore.addHeaderButton(installation, contribution);
|
||||
},
|
||||
openPanel(panelId, options) {
|
||||
openClientPanel({ installation, runtime, state, panelId, options });
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import { PluginClientStateProvider } from "@getpaseo/plugin/client/host";
|
||||
import type { PluginComposerPillProps } from "@getpaseo/plugin/client";
|
||||
import type { PluginTheme } from "@getpaseo/plugin";
|
||||
import { useCallback, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { Platform, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { composerPillStyles } from "@/composer/pill-styles";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import type { InstalledPlugin, PluginComposerPillContribution } from "../types";
|
||||
import { pluginComposerPillStore } from "./store";
|
||||
|
||||
const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
|
||||
function resolvePlatform(): PluginComposerPillProps["layout"]["platform"] {
|
||||
if (Platform.OS === "ios") return "ios";
|
||||
if (Platform.OS === "android") return "android";
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginComposerPill({
|
||||
plugin,
|
||||
contribution,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
compact,
|
||||
hostLabel,
|
||||
theme,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
contribution: PluginComposerPillContribution;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
compact: boolean;
|
||||
hostLabel: string;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const toast = useToast();
|
||||
const [pending, setPending] = useState(false);
|
||||
const runtime = useMemo(() => createPluginSurfaceRuntime(client, plugin.id), [client, plugin.id]);
|
||||
const state = useMemo(() => createPluginClientStateSource(serverId), [serverId]);
|
||||
const props = useMemo<PluginComposerPillProps>(
|
||||
() => ({
|
||||
theme,
|
||||
host: { id: serverId, label: hostLabel },
|
||||
layout: { compact, platform: resolvePlatform() },
|
||||
workspaceId,
|
||||
agentId,
|
||||
}),
|
||||
[agentId, compact, hostLabel, serverId, theme, workspaceId],
|
||||
);
|
||||
const press = useCallback(async () => {
|
||||
if (!runtime || pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await contribution.onPress();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [contribution, pending, runtime, toast]);
|
||||
const pillStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
composerPillStyles.body,
|
||||
styles.pill,
|
||||
(hovered || pressed) && composerPillStyles.bodyActive,
|
||||
pending && styles.disabled,
|
||||
],
|
||||
[pending],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ busy: pending, disabled: pending }), [pending]);
|
||||
if (!runtime) return null;
|
||||
const Component = contribution.Component;
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={Component}>
|
||||
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
|
||||
<PluginClientStateProvider source={state}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={contribution.title}
|
||||
accessibilityState={accessibilityState}
|
||||
disabled={pending}
|
||||
onPress={press}
|
||||
style={pillStyle}
|
||||
>
|
||||
<Component {...props} />
|
||||
{pending ? <LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> : null}
|
||||
</Pressable>
|
||||
</PluginClientStateProvider>
|
||||
</PluginRuntimeBoundary>
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ThemedPluginComposerPill = withUnistyles(PluginComposerPill);
|
||||
|
||||
export function PluginComposerPills({
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
compact,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const registrations = useSyncExternalStore(
|
||||
pluginComposerPillStore.subscribe,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
);
|
||||
const hosts = useHosts();
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
registrations.filter(
|
||||
({ installation, contribution }) =>
|
||||
installation.serverId === serverId &&
|
||||
contribution.workspaceId === workspaceId &&
|
||||
contribution.agentId === agentId,
|
||||
),
|
||||
[agentId, registrations, serverId, workspaceId],
|
||||
);
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
return (
|
||||
<>
|
||||
{visible.map(({ installation, contribution }) => (
|
||||
<ThemedPluginComposerPill
|
||||
key={`${installation.id}/${contribution.id}`}
|
||||
plugin={installation}
|
||||
contribution={contribution}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
compact={compact}
|
||||
hostLabel={hostLabel}
|
||||
uniProps={pluginThemeMapping}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useHasPluginComposerPills(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
const registrations = useSyncExternalStore(
|
||||
pluginComposerPillStore.subscribe,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
pluginComposerPillStore.getSnapshot,
|
||||
);
|
||||
return registrations.some(
|
||||
({ installation, contribution }) =>
|
||||
installation.serverId === serverId &&
|
||||
contribution.workspaceId === workspaceId &&
|
||||
contribution.agentId === agentId,
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
pill: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
}));
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { PluginComposerPillContribution } from "@getpaseo/plugin/client";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
|
||||
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
|
||||
|
||||
export interface RegisteredPluginComposerPill {
|
||||
installation: InstalledPlugin;
|
||||
contribution: PluginComposerPillContribution;
|
||||
}
|
||||
|
||||
class PluginComposerPillStore {
|
||||
private entries: RegisteredPluginComposerPill[] = [];
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
getSnapshot = (): readonly RegisteredPluginComposerPill[] => this.entries;
|
||||
|
||||
add(installation: InstalledPlugin, input: PluginComposerPillContribution): () => void {
|
||||
const contribution = validateContribution(input);
|
||||
const duplicate = this.entries.some(
|
||||
(entry) =>
|
||||
entry.installation === installation &&
|
||||
entry.contribution.workspaceId === contribution.workspaceId &&
|
||||
entry.contribution.agentId === contribution.agentId &&
|
||||
entry.contribution.id === contribution.id,
|
||||
);
|
||||
if (duplicate) {
|
||||
throw new Error(`Duplicate composer pill: ${contribution.id}`);
|
||||
}
|
||||
const entry = { installation, contribution };
|
||||
this.entries = [...this.entries, entry];
|
||||
this.publish();
|
||||
let removed = false;
|
||||
return () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
this.entries = this.entries.filter((candidate) => candidate !== entry);
|
||||
this.publish();
|
||||
};
|
||||
}
|
||||
|
||||
removeInstallation(installation: InstalledPlugin): void {
|
||||
const next = this.entries.filter((entry) => entry.installation !== installation);
|
||||
if (next.length === this.entries.length) return;
|
||||
this.entries = next;
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private publish(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
function validateContribution(
|
||||
contribution: PluginComposerPillContribution,
|
||||
): PluginComposerPillContribution {
|
||||
const id = contribution.id.trim();
|
||||
const title = contribution.title.trim();
|
||||
const workspaceId = contribution.workspaceId.trim();
|
||||
const agentId = contribution.agentId.trim();
|
||||
if (!CONTRIBUTION_ID.test(id)) throw new Error(`Invalid composer pill id: ${contribution.id}`);
|
||||
if (!title) throw new Error(`Composer pill ${id} has no title`);
|
||||
if (!workspaceId) throw new Error(`Composer pill ${id} has no workspace`);
|
||||
if (!agentId) throw new Error(`Composer pill ${id} has no agent`);
|
||||
if (typeof contribution.Component !== "function") {
|
||||
throw new Error(`Composer pill ${id} is not a component`);
|
||||
}
|
||||
if (typeof contribution.onPress !== "function") {
|
||||
throw new Error(`Composer pill ${id} has no callback`);
|
||||
}
|
||||
return { ...contribution, id, title, workspaceId, agentId };
|
||||
}
|
||||
|
||||
export const pluginComposerPillStore = new PluginComposerPillStore();
|
||||
@@ -7,8 +7,11 @@ const runtime = {
|
||||
openSettings() {},
|
||||
openSurface() {},
|
||||
openPanel() {},
|
||||
addHeaderButton() {
|
||||
return { update() {}, remove() {} };
|
||||
},
|
||||
addComposerPill() {
|
||||
return () => undefined;
|
||||
return { update() {}, remove() {} };
|
||||
},
|
||||
} as unknown as PluginClientRuntime;
|
||||
|
||||
@@ -25,6 +28,31 @@ function bundle(body: string): string {
|
||||
}
|
||||
|
||||
describe("evaluatePluginClientBundle", () => {
|
||||
it("releases button registrations when client setup throws", () => {
|
||||
let active = 0;
|
||||
function addButton() {
|
||||
active++;
|
||||
return {
|
||||
update() {},
|
||||
remove() {
|
||||
active--;
|
||||
},
|
||||
};
|
||||
}
|
||||
expect(() =>
|
||||
runPluginClientBundle(
|
||||
"failed-buttons",
|
||||
bundle(`
|
||||
plugin.addHeaderButton({ id: "header", workspaceId: "workspace", button: { title: "Header", icon: "Scan", behavior: { kind: "action", onPress() {} } } });
|
||||
plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } });
|
||||
throw new Error("Setup failed");
|
||||
`),
|
||||
{ ...runtime, addHeaderButton: addButton, addComposerPill: addButton },
|
||||
),
|
||||
).toThrow("Setup failed");
|
||||
expect(active).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts memoized settings screens", () => {
|
||||
const plugin = evaluatePluginClientBundle(
|
||||
"settings",
|
||||
@@ -50,7 +78,7 @@ describe("evaluatePluginClientBundle", () => {
|
||||
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
|
||||
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
|
||||
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
|
||||
plugin.addComposerPill({ id: "pill", title: "Pill", workspaceId: "workspace", agentId: "agent", Component, onPress() {} }),
|
||||
plugin.addComposerPill({ id: "pill", workspaceId: "workspace", agentId: "agent", button: { title: "Pill", icon: "Scan", behavior: { kind: "action", onPress() {} } } }).remove,
|
||||
plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }),
|
||||
plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }),
|
||||
plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }),
|
||||
@@ -61,8 +89,11 @@ describe("evaluatePluginClientBundle", () => {
|
||||
...runtime,
|
||||
addComposerPill() {
|
||||
pillCount += 1;
|
||||
return () => {
|
||||
pillCount -= 1;
|
||||
return {
|
||||
update() {},
|
||||
remove() {
|
||||
pillCount -= 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type PluginTimelineRendererContribution,
|
||||
type PluginTimelineTransformerContribution,
|
||||
type PluginWorkspacePanelContribution,
|
||||
type PluginButtonRegistration,
|
||||
} from "@getpaseo/plugin/client";
|
||||
import type { EvaluatedPlugin } from "./types";
|
||||
import type { ComponentType } from "react";
|
||||
@@ -70,7 +71,13 @@ function requireId(value: string, label: string): string {
|
||||
|
||||
export type PluginClientRuntime = Pick<
|
||||
PluginClientContext,
|
||||
"paseo" | "rpc" | "openSettings" | "openSurface" | "openPanel" | "addComposerPill"
|
||||
| "paseo"
|
||||
| "rpc"
|
||||
| "openSettings"
|
||||
| "openSurface"
|
||||
| "openPanel"
|
||||
| "addComposerPill"
|
||||
| "addHeaderButton"
|
||||
>;
|
||||
|
||||
export function runPluginClientBundle(
|
||||
@@ -103,6 +110,23 @@ export function runPluginClientBundle(
|
||||
const timelineRendererIds = new Set<string>();
|
||||
const removals = new Set<PluginCleanup>();
|
||||
let setupComplete = false;
|
||||
let stopped = false;
|
||||
function trackButton(registration: PluginButtonRegistration): PluginButtonRegistration {
|
||||
let active = true;
|
||||
const remove = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
registration.remove();
|
||||
removals.delete(remove);
|
||||
};
|
||||
removals.add(remove);
|
||||
return {
|
||||
update: (patch) => {
|
||||
if (active && !stopped) registration.update(patch);
|
||||
},
|
||||
remove,
|
||||
};
|
||||
}
|
||||
const notifyChange = () => {
|
||||
if (setupComplete) onChange();
|
||||
};
|
||||
@@ -336,16 +360,12 @@ export function runPluginClientBundle(
|
||||
);
|
||||
},
|
||||
addComposerPill(contribution) {
|
||||
const removePill = runtime.addComposerPill(contribution);
|
||||
let active = true;
|
||||
const remove = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
removePill();
|
||||
removals.delete(remove);
|
||||
};
|
||||
removals.add(remove);
|
||||
return remove;
|
||||
if (stopped) throw new Error("Plugin has stopped");
|
||||
return trackButton(runtime.addComposerPill(contribution));
|
||||
},
|
||||
addHeaderButton(contribution) {
|
||||
if (stopped) throw new Error("Plugin has stopped");
|
||||
return trackButton(runtime.addHeaderButton(contribution));
|
||||
},
|
||||
};
|
||||
const runtimeRequire = (name: string): unknown => {
|
||||
@@ -372,9 +392,16 @@ export function runPluginClientBundle(
|
||||
if (typeof setup !== "function") {
|
||||
throw new Error(`Plugin ${id} must default export a function`);
|
||||
}
|
||||
const entryCleanup = setup(pluginContext);
|
||||
if (typeof entryCleanup !== "function") {
|
||||
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
|
||||
let entryCleanup: PluginCleanup;
|
||||
try {
|
||||
entryCleanup = setup(pluginContext);
|
||||
if (typeof entryCleanup !== "function") {
|
||||
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
|
||||
}
|
||||
} catch (error) {
|
||||
stopped = true;
|
||||
for (const remove of removals) remove();
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -384,6 +411,8 @@ export function runPluginClientBundle(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
stopped = true;
|
||||
for (const remove of removals) remove();
|
||||
try {
|
||||
void Promise.resolve(entryCleanup()).catch((cleanupError) => {
|
||||
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
|
||||
@@ -394,7 +423,6 @@ export function runPluginClientBundle(
|
||||
throw error;
|
||||
}
|
||||
setupComplete = true;
|
||||
let stopped = false;
|
||||
const cleanup = async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
|
||||
@@ -3,4 +3,8 @@ export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
|
||||
export { PluginSurfaceScreen } from "./surface-screen";
|
||||
export { usePluginAttachmentPicker } from "./attachments/picker";
|
||||
export { PluginResourceAttachmentPill } from "./attachments/pill";
|
||||
export { PluginComposerPills, useHasPluginComposerPills } from "./composer-pills";
|
||||
export {
|
||||
PluginComposerPills,
|
||||
PluginHeaderButtons,
|
||||
useHasPluginComposerPills,
|
||||
} from "./buttons/view";
|
||||
|
||||
@@ -22,7 +22,8 @@ function registry(version: string) {
|
||||
},
|
||||
openSurface() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => () => {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ vi.mock("./client-runtime", () => ({
|
||||
rpc: async () => undefined,
|
||||
openSurface: () => undefined,
|
||||
openPanel: () => undefined,
|
||||
addComposerPill: () => () => undefined,
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ vi.mock("../client-runtime", () => ({
|
||||
rpc: async () => undefined,
|
||||
openSurface: () => undefined,
|
||||
openPanel: () => undefined,
|
||||
addComposerPill: () => () => undefined,
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../icons", () => ({
|
||||
|
||||
@@ -13,7 +13,8 @@ vi.mock("../client-runtime", () => ({
|
||||
rpc: async () => undefined,
|
||||
openSurface: () => undefined,
|
||||
openPanel: () => undefined,
|
||||
addComposerPill: () => () => undefined,
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -501,8 +501,6 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
}
|
||||
: undefined;
|
||||
const appCapabilities = {
|
||||
[CLIENT_CAPS.selectiveAgentTimeline]: true,
|
||||
[CLIENT_CAPS.timelineReplacementInvalidation]: true,
|
||||
...browserAutomationCapabilities,
|
||||
};
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
@@ -85,10 +86,15 @@ import { useProjectIcons } from "@/projects/icons";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { ComposerAttachment } from "@/attachments/types";
|
||||
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { requestWorkspaceDraftAgent } from "@/composer/draft/create-agent-request";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client";
|
||||
import type {
|
||||
CreatePaseoWorktreeInput,
|
||||
DaemonClient,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty";
|
||||
@@ -123,6 +129,8 @@ import {
|
||||
type ListTerminalsPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
} from "./workspace/terminals/state";
|
||||
import { createWorkspaceAgentInBackground } from "./new-workspace/background-handoff";
|
||||
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
|
||||
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -747,10 +755,19 @@ function normalizeBranchDetails(
|
||||
return names.map((name) => ({ name, committerDate: 0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* "background" means the user left the New workspace screen mid-creation, so nothing navigated
|
||||
* and the screen — if still mounted under another route — has to drop its pending state itself.
|
||||
*/
|
||||
type SubmitOutcome = "navigated" | "background";
|
||||
|
||||
interface SubmitDraftInput {
|
||||
draftVersionAtSubmit: number | undefined;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftId?: string;
|
||||
draftContextScopeKey: string | null;
|
||||
initialSetup?: WorkspaceDraftTabSetup;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string;
|
||||
@@ -759,6 +776,8 @@ interface SubmitDraftInput {
|
||||
provider: AgentProvider;
|
||||
composerState: NewWorkspaceComposerState;
|
||||
supportsForgeSearch: boolean;
|
||||
resolveClient: () => DaemonClient;
|
||||
isStillOnCreateScreen: () => boolean;
|
||||
}
|
||||
|
||||
type NewWorkspaceComposerState = NonNullable<
|
||||
@@ -860,9 +879,13 @@ interface CreateChatAgentInput {
|
||||
withInitialAgent: boolean;
|
||||
}) => Promise<ReturnType<typeof normalizeWorkspaceDescriptor>>;
|
||||
serverId: string;
|
||||
draftKey: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
draftId?: string;
|
||||
draftContextScopeKey: string | null;
|
||||
supportsForgeSearch: boolean;
|
||||
resolveClient: () => DaemonClient;
|
||||
isStillOnCreateScreen: () => boolean;
|
||||
labels: {
|
||||
composerStateRequired: string;
|
||||
selectModel: string;
|
||||
@@ -918,8 +941,9 @@ function buildComposerInitialValues(input: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
async function runCreateChatAgent(input: CreateChatAgentInput): Promise<SubmitOutcome> {
|
||||
const { payload, composerState, ensureWorkspace, serverId, clearDraft } = input;
|
||||
const draftVersionAtSubmit = useDraftStore.getState().drafts[input.draftKey]?.version;
|
||||
const { text, attachments, cwd } = payload;
|
||||
if (!composerState) {
|
||||
throw new Error(input.labels.composerStateRequired);
|
||||
@@ -947,10 +971,13 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
provider,
|
||||
composerState,
|
||||
});
|
||||
submitWorkspaceDraft({
|
||||
return await submitWorkspaceDraft({
|
||||
draftVersionAtSubmit,
|
||||
serverId,
|
||||
clearDraft,
|
||||
draftKey: input.draftKey,
|
||||
draftId: input.draftId,
|
||||
draftContextScopeKey: input.draftContextScopeKey,
|
||||
initialSetup,
|
||||
workspaceId: ensuredWorkspace.id,
|
||||
workspaceDirectory: ensuredWorkspace.workspaceDirectory,
|
||||
@@ -959,6 +986,8 @@ async function runCreateChatAgent(input: CreateChatAgentInput): Promise<void> {
|
||||
provider,
|
||||
composerState,
|
||||
supportsForgeSearch: input.supportsForgeSearch,
|
||||
resolveClient: input.resolveClient,
|
||||
isStillOnCreateScreen: input.isStillOnCreateScreen,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1021,7 +1050,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
async function submitWorkspaceDraft(input: SubmitDraftInput): Promise<SubmitOutcome> {
|
||||
const {
|
||||
serverId,
|
||||
clearDraft,
|
||||
@@ -1049,6 +1078,37 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
composerState,
|
||||
initialSetup,
|
||||
});
|
||||
// Creation blocks on a slow daemon RPC. If the user moved on while it ran, the destination
|
||||
// screen's draft tab will never mount to issue create_agent, so this path does it instead.
|
||||
if (!input.isStillOnCreateScreen()) {
|
||||
await createWorkspaceAgentInBackground({
|
||||
draftVersionAtSubmit: input.draftVersionAtSubmit,
|
||||
draftId,
|
||||
draftKey: input.draftKey,
|
||||
clearDraft,
|
||||
draftContextScopeKey: input.draftContextScopeKey,
|
||||
createAgent: () =>
|
||||
requestWorkspaceDraftAgent(input.resolveClient(), {
|
||||
workspaceId,
|
||||
config: buildWorkspaceDraftAgentConfig({
|
||||
provider: submission.provider,
|
||||
cwd: submission.cwd,
|
||||
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
||||
...(submission.model ? { model: submission.model } : {}),
|
||||
...(submission.thinkingOptionId
|
||||
? { thinkingOptionId: submission.thinkingOptionId }
|
||||
: {}),
|
||||
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
|
||||
}),
|
||||
text: text.trim(),
|
||||
clientMessageId,
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
}),
|
||||
});
|
||||
return "background";
|
||||
}
|
||||
|
||||
useCreateFlowStore.getState().setPending({
|
||||
serverId,
|
||||
draftId,
|
||||
@@ -1082,6 +1142,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
workspaceId,
|
||||
target: submission.target,
|
||||
});
|
||||
return "navigated";
|
||||
}
|
||||
|
||||
function useNewWorkspaceHostSelector(input: {
|
||||
@@ -1591,6 +1652,7 @@ export function NewWorkspaceScreen({
|
||||
const isolationPickerAnchorRef = useRef<View>(null);
|
||||
const hostPickerAnchorRef = useRef<View | null>(null);
|
||||
const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId });
|
||||
const isStillOnCreateScreen = useNewWorkspaceScreenPresence();
|
||||
|
||||
// Launch target: what the composer submits to (chat agent, or a terminal
|
||||
// profile). Mirrors useWorkspaceIsolation's pattern below: the derived
|
||||
@@ -1696,11 +1758,12 @@ export function NewWorkspaceScreen({
|
||||
}, []);
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
const connectedClient = getHostRuntimeStore().getClient(selectedServerId);
|
||||
if (!connectedClient?.isConnected) {
|
||||
throw new Error(t("newWorkspace.errors.hostDisconnected"));
|
||||
}
|
||||
return client;
|
||||
}, [client, isConnected, t]);
|
||||
return connectedClient;
|
||||
}, [selectedServerId, t]);
|
||||
|
||||
const clientReady = isConnected && Boolean(client);
|
||||
const hasSelectedSourceDirectory = selectedSourceDirectory !== null;
|
||||
@@ -2043,31 +2106,49 @@ export function NewWorkspaceScreen({
|
||||
await updateFormPreferences({ launchTarget });
|
||||
if (isEmptyWorkspaceSubmission(payload)) {
|
||||
setPendingAction("empty");
|
||||
let outcome: SubmitOutcome = "background";
|
||||
await runCreateEmptyWorkspace({
|
||||
payload,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
navigate: (targetServerId, workspaceId) =>
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId }),
|
||||
navigate: (targetServerId, workspaceId) => {
|
||||
if (!isStillOnCreateScreen()) {
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId });
|
||||
},
|
||||
});
|
||||
// Nothing navigated, so this screen may still be mounted under another route. Release
|
||||
// the pending lock it would otherwise keep forever.
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction("chat");
|
||||
await runCreateChatAgent({
|
||||
const outcome = await runCreateChatAgent({
|
||||
payload,
|
||||
composerState,
|
||||
forkDraftSetup,
|
||||
ensureWorkspace,
|
||||
serverId: selectedServerId,
|
||||
clearDraft: chatDraft.clear,
|
||||
draftKey,
|
||||
draftId,
|
||||
draftContextScopeKey,
|
||||
supportsForgeSearch,
|
||||
resolveClient: withConnectedClient,
|
||||
isStillOnCreateScreen,
|
||||
labels: {
|
||||
composerStateRequired: t("newWorkspace.errors.composerStateRequired"),
|
||||
selectModel: t("newWorkspace.errors.selectModel"),
|
||||
},
|
||||
});
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
@@ -2077,16 +2158,20 @@ export function NewWorkspaceScreen({
|
||||
},
|
||||
[
|
||||
composerState,
|
||||
draftContextScopeKey,
|
||||
draftId,
|
||||
chatDraft.clear,
|
||||
draftKey,
|
||||
ensureWorkspace,
|
||||
forkDraftSetup,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
selectedServerId,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
toast,
|
||||
updateFormPreferences,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2095,6 +2180,7 @@ export function NewWorkspaceScreen({
|
||||
setErrorMessage(null);
|
||||
await updateFormPreferences({ launchTarget });
|
||||
setPendingAction("terminal");
|
||||
let outcome: SubmitOutcome = "background";
|
||||
await runCreateTerminalWorkspace({
|
||||
cwd: selectedSourceDirectory ?? "",
|
||||
prompt: terminalPromptText,
|
||||
@@ -2128,9 +2214,20 @@ export function NewWorkspaceScreen({
|
||||
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
|
||||
},
|
||||
serverId: selectedServerId,
|
||||
navigate: (targetServerId, workspaceId, target) =>
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target }),
|
||||
// The terminal is spawned and fed its command before this runs, so skipping the
|
||||
// navigation costs nothing: it is standalone, and `reconcileTabs` auto-opens standalone
|
||||
// terminals when the workspace is next visited.
|
||||
navigate: (targetServerId, workspaceId, target) => {
|
||||
if (!isStillOnCreateScreen()) {
|
||||
return;
|
||||
}
|
||||
outcome = "navigated";
|
||||
navigateToWorkspace({ serverId: targetServerId, workspaceId, target });
|
||||
},
|
||||
});
|
||||
if (outcome === "background") {
|
||||
setPendingAction(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setPendingAction(null);
|
||||
@@ -2139,6 +2236,7 @@ export function NewWorkspaceScreen({
|
||||
}
|
||||
}, [
|
||||
ensureWorkspace,
|
||||
isStillOnCreateScreen,
|
||||
launchTarget,
|
||||
queryClient,
|
||||
selectedServerId,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { createWorkspaceAgentInBackground } from "./background-handoff";
|
||||
|
||||
const DRAFT_ID = "draft_1";
|
||||
const DRAFT_KEY = "new-workspace";
|
||||
const SCOPE_KEY = "draft:draft_1";
|
||||
|
||||
function saveDraft(text: string) {
|
||||
useDraftStore
|
||||
.getState()
|
||||
.saveDraftInput({ draftKey: DRAFT_KEY, draft: { text, attachments: [] } });
|
||||
}
|
||||
|
||||
function draftText(): string | undefined {
|
||||
return useDraftStore.getState().drafts[DRAFT_KEY]?.input.text;
|
||||
}
|
||||
|
||||
function run(
|
||||
createAgent: () => Promise<unknown>,
|
||||
draftVersionAtSubmit = useDraftStore.getState().drafts[DRAFT_KEY]?.version,
|
||||
) {
|
||||
return createWorkspaceAgentInBackground({
|
||||
draftId: DRAFT_ID,
|
||||
draftKey: DRAFT_KEY,
|
||||
draftVersionAtSubmit,
|
||||
clearDraft: (lifecycle) =>
|
||||
useDraftStore.getState().clearDraftInput({ draftKey: DRAFT_KEY, lifecycle }),
|
||||
draftContextScopeKey: SCOPE_KEY,
|
||||
createAgent,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useWorkspaceDraftSubmissionStore.setState({ pendingByDraftId: {}, setupByDraftId: {} });
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
useWorkspaceAttachmentsStore.setState({ attachmentsByScope: { [SCOPE_KEY]: [] } });
|
||||
saveDraft("do the thing");
|
||||
});
|
||||
|
||||
describe("createWorkspaceAgentInBackground", () => {
|
||||
it("creates one agent and clears the consumed draft setup, attachments and input", async () => {
|
||||
let calls = 0;
|
||||
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
|
||||
draftId: DRAFT_ID,
|
||||
setup: {
|
||||
cwd: "/work/repo",
|
||||
provider: "claude",
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
},
|
||||
});
|
||||
|
||||
await run(async () => {
|
||||
calls += 1;
|
||||
});
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().setupByDraftId).toEqual({});
|
||||
expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[SCOPE_KEY]).toBeUndefined();
|
||||
expect(draftText()).toBe("");
|
||||
});
|
||||
|
||||
it("leaves no pending submission behind, so no draft tab can create a second agent", async () => {
|
||||
await run(async () => undefined);
|
||||
|
||||
expect(useWorkspaceDraftSubmissionStore.getState().pendingByDraftId).toEqual({});
|
||||
expect(useCreateFlowStore.getState().pendingByDraftId).toEqual({});
|
||||
});
|
||||
|
||||
it("rethrows a failed creation and keeps the prompt for retry", async () => {
|
||||
const failure = new Error("daemon exploded");
|
||||
|
||||
await expect(
|
||||
run(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
).rejects.toThrow(failure);
|
||||
|
||||
expect(draftText()).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("keeps a prompt typed into the shared draft while creation was in flight", async () => {
|
||||
await run(async () => {
|
||||
saveDraft("a different idea");
|
||||
});
|
||||
|
||||
expect(draftText()).toBe("a different idea");
|
||||
});
|
||||
it("keeps a newer draft written while workspace creation was pending", async () => {
|
||||
const submittedVersion = useDraftStore.getState().drafts[DRAFT_KEY]?.version;
|
||||
saveDraft("a newer workspace idea");
|
||||
await run(async () => undefined, submittedVersion);
|
||||
expect(draftText()).toBe("a newer workspace idea");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user