Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bab754714e | |||
| 67063464e7 | |||
| dd23898769 | |||
| 612b146fd6 | |||
| 4f57114f19 | |||
| 166b065ad4 | |||
| 9f2c840ae7 | |||
| 99084cc5a5 | |||
| 475f95ba70 | |||
| 12b9b83d51 | |||
| a792c577ef | |||
| 7cc15cca92 |
@@ -0,0 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Pi Status Bridge are documented here. The project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- The bridge now supports durable independently live session runtimes grouped by worktree, with a multi-session desktop workspace.
|
||||
- Noctalia v4/v5 are aggregate status launchers: they report open, working, attention, recovery, and error counts and toggle the desktop UI without controlling sessions.
|
||||
|
||||
## [0.1.0] - 2026-07-27
|
||||
|
||||
### Added
|
||||
|
||||
- Local Unix-socket bridge for Pi RPC agents and worktree-aware session management.
|
||||
- Bounded Pi child recovery, local client CLI, and systemd user-service example.
|
||||
- Tauri desktop UI and Noctalia v4/v5 presentation adapters.
|
||||
- Automated bridge, protocol, adapter, and desktop tests.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Contributing to Pi Status Bridge
|
||||
|
||||
## Reason for existence
|
||||
|
||||
Keep contributions reviewable and preserve the bridge's worktree isolation, local-only transport, and Pi-owned approval policy.
|
||||
|
||||
## Before coding
|
||||
|
||||
1. Create a focused branch; do not develop directly on `main`.
|
||||
2. Read [`DESIGN.md`](DESIGN.md) and the relevant document under [`specs/`](specs/).
|
||||
3. State which boundary changes: bridge protocol, Pi RPC adapter, desktop UI, or Noctalia adapter.
|
||||
4. Add or update the narrowest test that proves the behavior.
|
||||
|
||||
## Development workflow
|
||||
|
||||
```bash
|
||||
npm --prefix ui ci
|
||||
npm test
|
||||
npm run check
|
||||
```
|
||||
|
||||
Use Node.js 20+ and Rust/Tauri tooling for UI work. Keep generated directories untracked: `ui/node_modules/`, `ui/dist/`, `ui/src-tauri/target/`, and `ui/src-tauri/gen/schemas/`.
|
||||
|
||||
## Required invariants
|
||||
|
||||
- **Never** expose a TCP listener in v1.
|
||||
- **Never** route a request to a worktree other than the explicitly selected agent.
|
||||
- **Never** let the bridge, desktop client, or Noctalia adapters decide an extension approval.
|
||||
- **Never** commit runtime sockets, sessions, logs, build outputs, secrets, or agent artifacts.
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Use a Conventional Commit-style title, for example `fix(protocol): reject malformed frames`.
|
||||
2. Explain the user-visible behavior and the invariant protected.
|
||||
3. Include tests for the changed contract; include manual verification steps for desktop or Noctalia changes.
|
||||
4. Run `npm test` and `npm run check` before requesting review.
|
||||
5. Push the feature branch and merge through the forge workflow; do not directly push `main`.
|
||||
|
||||
## Documentation changes
|
||||
|
||||
Update the closest authoritative document when behavior, setup, security boundaries, or test coverage changes. Keep the root README concise and link detailed design material rather than duplicating it.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm test && npm run check
|
||||
```
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Alex Blank
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Pi Status Bridge
|
||||
|
||||
> A local Unix-socket bridge and desktop UI for monitoring and controlling persistent [Pi](https://github.com/badlogic/pi-mono) coding-agent sessions without bypassing Pi-owned safety policy.
|
||||
|
||||
Pi Status Bridge keeps independently live Pi session runtimes grouped by worktree, exposes a local-only protocol, and provides desktop and Noctalia adapters. It is experimental software: use it on worktrees you control and verify the documented security boundaries before daily use.
|
||||
|
||||
## What it provides
|
||||
|
||||
- A local bridge that owns Pi RPC child processes, sessions, recovery, and worktree routing.
|
||||
- A same-user Unix-domain socket; it never listens on TCP.
|
||||
- A Tauri + React desktop client for agents, transcripts, prompts, model/thinking controls, and extension requests.
|
||||
- Presentation-only adapters for Noctalia v4 and v5.
|
||||
- A local client CLI for request/response and event subscriptions.
|
||||
- Bounded recovery for unexpected Pi child exits.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20 or newer.
|
||||
- A working `pi` command available on `PATH`; the bridge starts Pi with `pi --mode rpc`.
|
||||
- Linux or another Unix-like environment with an absolute `XDG_RUNTIME_DIR`.
|
||||
- For the desktop UI: Rust stable and the native prerequisites required by [Tauri v2](https://v2.tauri.app/start/prerequisites/).
|
||||
- For Noctalia integration: a compatible Noctalia v4 or v5 installation.
|
||||
|
||||
## Quick start
|
||||
|
||||
Clone the repository and install the desktop UI dependencies:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@git.commumedia.org:2222/alex/pi-gui.git
|
||||
cd pi-gui
|
||||
npm --prefix ui ci
|
||||
```
|
||||
|
||||
Start the bridge for your home worktree. `XDG_RUNTIME_DIR` must already be set by the desktop session.
|
||||
|
||||
```bash
|
||||
node src/bridge/cli.js \
|
||||
--worktree "$HOME" \
|
||||
--session-root "$HOME/.local/state/pi-status-bridge/sessions"
|
||||
```
|
||||
|
||||
The bridge prints its socket path as JSON. In another terminal, start the desktop client:
|
||||
|
||||
```bash
|
||||
npm --prefix ui run tauri dev
|
||||
```
|
||||
|
||||
The desktop client reads `PI_STATUS_BRIDGE_SOCKET` when set; otherwise it uses `$XDG_RUNTIME_DIR/pi-status-bridge/bridge.sock`.
|
||||
|
||||
## CLI usage
|
||||
|
||||
### Bridge daemon
|
||||
|
||||
```bash
|
||||
node src/bridge/cli.js [--worktree <path>] [--runtime-dir <absolute-path>] [--session-root <path>]
|
||||
```
|
||||
|
||||
- `--worktree` defaults to the current user's home directory.
|
||||
- `--runtime-dir` overrides `XDG_RUNTIME_DIR`; it must be absolute.
|
||||
- `--session-root` selects where the bridge stores Pi session references.
|
||||
|
||||
### Local client
|
||||
|
||||
Use the printed socket path or export it once:
|
||||
|
||||
```bash
|
||||
export PI_STATUS_BRIDGE_SOCKET="$XDG_RUNTIME_DIR/pi-status-bridge/bridge.sock"
|
||||
node src/client/cli.js request '{"op":"list_agents"}'
|
||||
node src/client/cli.js subscribe --agent <agent-id>
|
||||
```
|
||||
|
||||
The request payload is the local protocol JSON. See [`DESIGN.md`](DESIGN.md) for the architectural contract and [`specs/FULL_PI_PANEL_SPEC.md`](specs/FULL_PI_PANEL_SPEC.md) for the desktop surface.
|
||||
|
||||
### Noctalia relay
|
||||
|
||||
```bash
|
||||
node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET"
|
||||
```
|
||||
|
||||
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. `--agent <agent-id>` retains the former agent-scoped relay only for external compatibility callers.
|
||||
|
||||
## Keep the bridge running in the background (recommended)
|
||||
|
||||
The desktop UI connects on demand; only the bridge needs to run persistently. On Linux systems using systemd, install the bridge command globally once, then use the included user service:
|
||||
|
||||
```bash
|
||||
npm install --global .
|
||||
command -v pi-status-bridge pi
|
||||
npm run bridge:service:install
|
||||
```
|
||||
|
||||
The npm commands below are short, copyable wrappers around `systemctl --user`:
|
||||
|
||||
```bash
|
||||
npm run bridge:service:start # start the bridge
|
||||
npm run bridge:service:restart # apply bridge changes or recover it
|
||||
npm run bridge:service:stop # stop the bridge
|
||||
npm run bridge:service:status # check whether it is healthy
|
||||
npm run bridge:service:logs # follow bridge logs; Ctrl+C returns to the shell
|
||||
```
|
||||
|
||||
If systemd cannot locate either executable, add a user-service drop-in that sets `PATH` to their containing directories; do not hard-code another user's home path in the tracked unit.
|
||||
|
||||
### Opening the desktop UI
|
||||
|
||||
- **Development:** run `npm --prefix ui run tauri dev` in a terminal after starting the bridge.
|
||||
- **Installed UI:** launch it from your application menu or run `pi-status-ui --toggle`. The `--toggle` command shows a hidden window or hides a visible one; it does not restart the bridge.
|
||||
- **Noctalia:** use the Pi Status launcher as usual; it opens the installed UI.
|
||||
|
||||
**Restart Pi** in the UI restarts only the selected directory's Pi agent. `npm run bridge:service:restart` restarts the whole bridge service and all of its active agents.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Bridge and protocol tests, then Tauri tests
|
||||
npm test
|
||||
|
||||
# JavaScript syntax checks and production UI build
|
||||
npm run check
|
||||
|
||||
# Desktop UI only
|
||||
npm --prefix ui run dev
|
||||
npm --prefix ui run tauri dev
|
||||
```
|
||||
|
||||
`npm test` is the repository acceptance command. It runs the Node test suite and the Tauri Rust tests. `npm run check` additionally builds the UI.
|
||||
|
||||
## Architecture and documentation
|
||||
|
||||
- [`docs/README.md`](docs/README.md) — documentation index.
|
||||
- [`DESIGN.md`](DESIGN.md) — system boundaries and security invariants.
|
||||
- [`IMPLEMENTATION_PLAN.md`](IMPLEMENTATION_PLAN.md) — implementation milestones.
|
||||
- [`TEST_PLAN.md`](TEST_PLAN.md) — automated, manual, and soak-test acceptance criteria.
|
||||
- [`specs/`](specs/) — detailed UI and Tauri architecture specifications.
|
||||
|
||||
## Contributing
|
||||
|
||||
Read [`CONTRIBUTING.md`](CONTRIBUTING.md) before opening a change. Keep prompts and worktree routing explicit, preserve local-only transport, and run both verification commands before review.
|
||||
|
||||
## Security
|
||||
|
||||
Read [`SECURITY.md`](SECURITY.md) before deployment or vulnerability reporting. The bridge must never introduce a network listener or bypass Pi extension approvals.
|
||||
|
||||
## License
|
||||
|
||||
Copyright 2026 Alex Blank. Licensed under the [Apache License 2.0](LICENSE).
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Security Policy
|
||||
|
||||
## Reason for existence
|
||||
|
||||
Pi Status Bridge controls local coding-agent sessions. Security reports and changes must preserve the same-user, local-only boundary.
|
||||
|
||||
## Supported versions
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| `0.1.x` | Yes |
|
||||
| Earlier versions | No |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Do **not** open a public issue for a suspected vulnerability. Report it privately to the repository maintainers through the hosting forge, including:
|
||||
|
||||
- affected commit or version;
|
||||
- reproduction steps and prerequisites;
|
||||
- impact, especially cross-user access, cross-worktree routing, or approval bypass; and
|
||||
- a proposed mitigation when available.
|
||||
|
||||
Maintainers will acknowledge the report, reproduce it, and coordinate disclosure before publishing details.
|
||||
|
||||
## Security boundaries
|
||||
|
||||
- The bridge listens only on a Unix-domain socket under `XDG_RUNTIME_DIR`.
|
||||
- The socket directory and socket are owner-only resources.
|
||||
- Agents are keyed by canonical worktree paths; requests cannot silently cross worktree boundaries.
|
||||
- Pi extensions retain ownership of confirmations and approvals.
|
||||
- Prompts and tool payloads must not be emitted in routine lifecycle logs.
|
||||
|
||||
A change that weakens any boundary above requires an explicit design update and security review before merge.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
The test suite covers socket ownership, malformed protocol messages, agent isolation, recovery, and adapter behavior.
|
||||
+7
-21
@@ -46,28 +46,14 @@ Prove that the always-running Pi bridge is correctly scoped, recoverable, and us
|
||||
|
||||
## Manual Noctalia checklist
|
||||
|
||||
### Startup and routing
|
||||
### Aggregate launcher
|
||||
|
||||
- [ ] Starting the bridge creates the home agent and shows it in the bar/popover.
|
||||
- [ ] Shortcut on a known focused worktree opens that worktree’s agent.
|
||||
- [ ] Missing or ambiguous focus opens the selector with home preselected.
|
||||
- [ ] Popover project/session manager switches agents explicitly and visibly.
|
||||
- [ ] Escape closes the popover without aborting streaming work.
|
||||
|
||||
### Status and conversation
|
||||
|
||||
- [ ] Bar shows state glyph/color, active project, and attention badge.
|
||||
- [ ] Popover streams assistant text and tool activity without blocking the composer.
|
||||
- [ ] Prompt, steer, follow-up, and abort map to the intended Pi action.
|
||||
- [ ] Queue changes are visible and do not reorder or lose user messages.
|
||||
- [ ] Pi model/thinking values display correctly and forwarded changes round-trip from Pi.
|
||||
|
||||
### Extension prompts
|
||||
|
||||
- [ ] Select, confirm, input, and editor requests render as native popover UI.
|
||||
- [ ] The response reaches the originating extension unchanged.
|
||||
- [ ] A pending request produces an attention badge while the popover is closed.
|
||||
- [ ] TUI-only unsupported custom UI is presented as an explicit unsupported/error state, never silently approved.
|
||||
- [ ] The widget shows aggregate open, working, attention, recovering, and error counts from `get_workspace_summary`.
|
||||
- [ ] A background session requiring extension attention increases the aggregate attention count.
|
||||
- [ ] Clicking the widget starts the bridge when needed and toggles the desktop UI.
|
||||
- [ ] Noctalia contains no composer, session switcher, or extension-response control.
|
||||
- [ ] The configured `PI_STATUS_UI_BINARY` and fallback binary both launch with `--toggle`.
|
||||
- [ ] Restarting Noctalia reconnects to the bridge summary without changing active session runtimes.
|
||||
|
||||
## Soak and fault scenarios
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Documentation index
|
||||
|
||||
## Reason for existence
|
||||
|
||||
Point contributors and operators to the authoritative document for each concern without duplicating implementation detail.
|
||||
|
||||
| Need | Authoritative document |
|
||||
| --- | --- |
|
||||
| Install, run, and verify the project | [`../README.md`](../README.md) |
|
||||
| Understand boundaries and security invariants | [`../DESIGN.md`](../DESIGN.md) |
|
||||
| Follow implementation milestones | [`../IMPLEMENTATION_PLAN.md`](../IMPLEMENTATION_PLAN.md) |
|
||||
| Execute acceptance and soak testing | [`../TEST_PLAN.md`](../TEST_PLAN.md) |
|
||||
| Understand the desktop product surface | [`../specs/FULL_PI_PANEL_SPEC.md`](../specs/FULL_PI_PANEL_SPEC.md) |
|
||||
| Understand Tauri integration | [`../specs/TAURI_CLIENT_ARCHITECTURE.md`](../specs/TAURI_CLIENT_ARCHITECTURE.md) |
|
||||
| Contribute safely | [`../CONTRIBUTING.md`](../CONTRIBUTING.md) |
|
||||
| Report vulnerabilities | [`../SECURITY.md`](../SECURITY.md) |
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
test -f README.md && test -f DESIGN.md && test -f TEST_PLAN.md
|
||||
```
|
||||
@@ -0,0 +1,66 @@
|
||||
# Quick Pane v1 Design
|
||||
|
||||
## Goal
|
||||
|
||||
Quick Pane is a small native window opened by a desktop/window-manager hotkey. It accepts one short request and returns either a useful answer or an explicit escalation within 60 seconds.
|
||||
|
||||
## Scope
|
||||
|
||||
- Cold-launchable, independent Tauri window.
|
||||
- Compact request input, quick-model picker, and workspace picker.
|
||||
- Configured default workspace, quick model, follow-up model, supplemental prompt, time limit, and read-only tool limit.
|
||||
- Ephemeral Quick Pane UI state and best-effort runtime cleanup.
|
||||
- Advisory read-only/no-subagent instructions, inline permission/clarification prompts, and explicit normal-session handoff.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Persisted quick history.
|
||||
- Named profiles.
|
||||
- App-managed WM binding writes.
|
||||
- Trusted prevention of mutable tools or direct edits.
|
||||
- Trusted prevention of subagents.
|
||||
- A memory-only Pi runtime; Pi may write temporary session data while running.
|
||||
|
||||
## Invocation
|
||||
|
||||
Desktop/WM configuration owns the hotkey so it works while Pi GUI is absent. Settings displays and copies the required command; it does not register or modify system bindings.
|
||||
|
||||
The app creates or focuses a dedicated small Quick Pane window. `Esc` closes it and discards quick-only state.
|
||||
|
||||
## Request lifecycle
|
||||
|
||||
1. Pane chooses configured default workspace and quick model, with per-request overrides.
|
||||
2. It starts an isolated Pi RPC runtime in the chosen workspace.
|
||||
3. Immutable prompt text instructs one request, read-only inspection, zero subagents, configured wall-clock budget, and configured tool-call budget. Current Pi RPC cannot trusted-enforce those instructions; event-derived caps and cleanup are best effort.
|
||||
4. Supplemental user instructions append beneath those prompt guardrails.
|
||||
5. Pane streams answer, tool status, and inline extension requests.
|
||||
6. On success it displays final answer. On a cap it preserves partial answer, explains the cap, and offers handoff.
|
||||
|
||||
## Escalation
|
||||
|
||||
Mutations, subagents, multi-step work, and likely-over-budget work preflight-escalate. The quick agent explains why. The user clicks **Continue in full session** to open a new normal session with:
|
||||
|
||||
- original request;
|
||||
- escalation note or partial findings;
|
||||
- selected workspace; and
|
||||
- configured follow-up model.
|
||||
|
||||
No full session opens automatically.
|
||||
|
||||
## Permissions and privacy
|
||||
|
||||
Quick Pane surfaces Pi extension `select`, `confirm`, `input`, and `editor` requests inline. User explicitly answers every prompt. Existing policy remains authoritative: ordinary workspace reads may be allowed, external-directory reads may ask, and sensitive paths may deny.
|
||||
|
||||
Quick Pane UI request, output, and transcript stay in memory only. Closing pane removes them and triggers best-effort quick-runtime cleanup. Pi may write temporary session data while the child runs. A deliberate handoff persists only in newly created normal-session history.
|
||||
|
||||
## Failure handling
|
||||
|
||||
Unavailable models, startup failures, and crashes render inline actionable errors: retry, pick another quick model, or open normal session. No silent fallback model is used.
|
||||
|
||||
## Settings validation
|
||||
|
||||
Settings reject invalid model, workspace, custom prompt, or numeric limit. Failed save keeps previously stored configuration and marks invalid fields.
|
||||
|
||||
## Acceptance
|
||||
|
||||
From cold or hot desktop state, the hotkey produces an answer or explicit escalation within 60 seconds. Automated component, bridge, persistence, and native tests cover behavior; a manual cold-launch/hotkey smoke test verifies desktop integration.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Quick Pane v1 Implementation Plan
|
||||
|
||||
## 1. Define persisted configuration
|
||||
|
||||
- Add Quick Pane settings types, defaults, validation, serialization, and migration-safe native storage.
|
||||
- Fields: default workspace, quick model, follow-up model, supplemental prompt, maximum seconds, maximum read-only tool calls.
|
||||
- Preserve current configuration when validation or save fails.
|
||||
|
||||
## 2. Add native window and launch contract
|
||||
|
||||
- Extend Tauri configuration and Rust window management with a dedicated Quick Pane window label.
|
||||
- Add command-line action used by external WM hotkey to create/show/focus pane, including cold launch.
|
||||
- Keep `--toggle` and ordinary `--new` behavior unchanged.
|
||||
|
||||
## 3. Build Quick Pane React surface
|
||||
|
||||
- Add isolated state reducer/component for request, selected model/workspace, progress, final answer, partial answer, escalation, extension UI, and errors.
|
||||
- Keep pane transcript in component memory only and request best-effort runtime/session cleanup on close.
|
||||
- Add Settings controls and copyable WM launch command.
|
||||
|
||||
## 4. Add constrained bridge runtime mode
|
||||
|
||||
- Extend runtime creation with Quick Pane metadata and ephemeral session lifecycle.
|
||||
- Build immutable advisory quick-agent instructions around supplemental custom prompt.
|
||||
- Use observed tool events and wall-clock cancellation for best-effort caps; document that current Pi RPC cannot trusted-enforce read-only/no-subagent policy or memory-only runtime sessions.
|
||||
- Instruct preflight escalation for mutation, subagent, multi-step, or likely-over-budget requests.
|
||||
|
||||
## 5. Route interaction and handoff
|
||||
|
||||
- Reuse existing extension request/response protocol for inline Quick Pane permission and clarification dialogs.
|
||||
- On explicit continuation, create normal runtime using workspace and follow-up model.
|
||||
- Prefill it with original request plus escalation note/partial findings.
|
||||
|
||||
## 6. Test and document
|
||||
|
||||
- Add unit tests for settings validation/persistence and guardrail classification.
|
||||
- Add component tests for pane states, picker behavior, inline prompts, error/retry, and cleanup.
|
||||
- Add Rust/bridge tests for window command routing, budget cancellation, ephemeral cleanup, and handoff payload.
|
||||
- Add manual WM hotkey/cold-launch instructions and smoke checklist.
|
||||
|
||||
## Sequencing
|
||||
|
||||
Land configuration/runtime contract first, then native window and UI, then handoff and test coverage. Preserve existing normal-session paths throughout.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Quick Pane v1 Test Plan and QA Checklist
|
||||
|
||||
## Automated coverage
|
||||
|
||||
### Settings
|
||||
|
||||
- Valid defaults load when no Quick Pane configuration exists.
|
||||
- Valid configuration round-trips through native storage.
|
||||
- Invalid workspace, model, prompt, time, and tool values show field errors and retain prior saved state.
|
||||
- Quick model picker affects quick request only; follow-up model remains configured default.
|
||||
|
||||
### Runtime guardrails
|
||||
|
||||
- Quick runtime receives immutable advisory read-only/no-subagent prompt text plus supplemental prompt.
|
||||
- Mutation, subagent, multi-step, and likely-over-budget requests are instructed to preflight-escalate.
|
||||
- Runtime applies best-effort configured 60-second/6-tool caps from observed events and exposes reason.
|
||||
- Partial result survives a cap; pane transcript is removed and runtime cleanup requested after pane close.
|
||||
- Tests/documentation state current limitations: Pi RPC cannot trusted-enforce prompt policy or guarantee memory-only temporary sessions.
|
||||
|
||||
### UI and bridge
|
||||
|
||||
- Quick Pane opens/focuses through dedicated native command without changing normal `--new` or `--toggle` behavior.
|
||||
- Workspace and quick-model picker values reach runtime creation.
|
||||
- Permission and clarification extension requests render inline and send correct response payloads.
|
||||
- Retry, model change, and normal-session actions appear for startup/model/crash errors.
|
||||
- Handoff creates normal session with original request, escalation note, workspace, and follow-up model.
|
||||
|
||||
## Manual smoke checklist
|
||||
|
||||
1. Configure default workspace, models, prompt, and 60-second/6-tool limits.
|
||||
2. Add desktop/WM hotkey using Settings launch command.
|
||||
3. From no running GUI process, invoke hotkey and verify Quick Pane opens/focuses.
|
||||
4. Submit short answer-only request; verify result within 60 seconds.
|
||||
5. Submit read-only inspection request; answer any external-read permission prompt inline.
|
||||
6. Submit mutation or multi-step request; verify reason and **Continue in full session**.
|
||||
7. Continue; verify normal session receives handoff and follow-up model.
|
||||
8. Close Quick Pane; reopen and verify quick transcript is absent.
|
||||
9. Select unavailable model or induce startup failure; verify inline recovery options.
|
||||
|
||||
## Exit criteria
|
||||
|
||||
All targeted automated tests pass. Manual cold-launch/hotkey smoke completes. No regressions in normal sessions, `--toggle`, or `--new` behavior.
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
@@ -16,13 +17,31 @@ Item {
|
||||
readonly property string screenName: screen?.name ?? ""
|
||||
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
|
||||
readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle"
|
||||
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home"
|
||||
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "0 sessions"
|
||||
readonly property int attentionCount: pluginApi?.pluginSettings?.attentionCount ?? 0
|
||||
readonly property string glyph: bridgeState === "streaming" ? "◒" : bridgeState === "failed" ? "!" : attentionCount > 0 ? "●" : "●"
|
||||
readonly property color stateColor: bridgeState === "failed" ? Color.mError : attentionCount > 0 ? Color.mPrimary : Color.mOnSurface
|
||||
readonly property int workingCount: pluginApi?.pluginSettings?.workingCount ?? 0
|
||||
readonly property int recoveringCount: pluginApi?.pluginSettings?.recoveringCount ?? 0
|
||||
readonly property int errorCount: pluginApi?.pluginSettings?.errorCount ?? 0
|
||||
readonly property string glyph: errorCount > 0 ? "!" : bridgeState === "streaming" ? "◒" : recoveringCount > 0 ? "↻" : attentionCount > 0 ? "●" : "●"
|
||||
readonly property color stateColor: errorCount > 0 ? Color.mError : attentionCount > 0 ? Color.mPrimary : Color.mOnSurface
|
||||
implicitWidth: content.implicitWidth + Style.marginM * 2
|
||||
implicitHeight: capsuleHeight
|
||||
|
||||
// Keep launch paths configurable for deployments while retaining the local build fallback.
|
||||
Process {
|
||||
id: bridgeStartProcess
|
||||
command: ["systemctl", "--user", "start", "pi-status-bridge.service"]
|
||||
onExited: function(exitCode) {
|
||||
if (exitCode === 0)
|
||||
uiLaunchProcess.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: uiLaunchProcess
|
||||
command: ["bash", "-c", "ui=\"${PI_STATUS_UI_BINARY:-$HOME/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui}\"; test -x \"$ui\" && exec setsid -f env TMPDIR=/tmp \"$ui\" --toggle >/tmp/pi-status-ui.log 2>&1"]
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: visualCapsule
|
||||
anchors.centerIn: parent
|
||||
@@ -46,10 +65,7 @@ Item {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
if (pluginApi)
|
||||
pluginApi.togglePanel(root.screen, visualCapsule)
|
||||
}
|
||||
onClicked: bridgeStartProcess.running = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,21 +5,25 @@ Item {
|
||||
id: root
|
||||
property var pluginApi: null
|
||||
|
||||
function setBridgeState(state, projectLabel, attentionCount, detail) {
|
||||
function setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount) {
|
||||
if (!pluginApi)
|
||||
return
|
||||
pluginApi.pluginSettings.bridgeState = state
|
||||
pluginApi.pluginSettings.projectLabel = projectLabel
|
||||
pluginApi.pluginSettings.attentionCount = attentionCount
|
||||
pluginApi.pluginSettings.detail = detail
|
||||
pluginApi.pluginSettings.openCount = openCount ?? 0
|
||||
pluginApi.pluginSettings.workingCount = workingCount ?? 0
|
||||
pluginApi.pluginSettings.recoveringCount = recoveringCount ?? 0
|
||||
pluginApi.pluginSettings.errorCount = errorCount ?? 0
|
||||
pluginApi.saveSettings()
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "plugin:pi-status-bridge"
|
||||
|
||||
function setBridgeState(state, projectLabel, attentionCount, detail) {
|
||||
root.setBridgeState(state, projectLabel, attentionCount, detail)
|
||||
function setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount) {
|
||||
root.setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
import qs.Widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
property var pluginApi: null
|
||||
readonly property var geometryPlaceholder: panelContainer
|
||||
readonly property bool allowAttach: true
|
||||
property real contentPreferredWidth: 680 * Style.uiScaleRatio
|
||||
property real contentPreferredHeight: 420 * Style.uiScaleRatio
|
||||
readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle"
|
||||
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home"
|
||||
readonly property string detail: pluginApi?.pluginSettings?.detail ?? "Bridge not connected"
|
||||
|
||||
anchors.fill: parent
|
||||
Rectangle {
|
||||
id: panelContainer
|
||||
anchors.fill: parent
|
||||
color: "transparent"
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL
|
||||
spacing: Style.marginM
|
||||
Text { text: "Pi Status Bridge"; color: Color.mOnSurface; font.bold: true; font.pixelSize: 20 }
|
||||
Text { text: "Project: " + root.projectLabel; color: Color.mOnSurfaceVariant }
|
||||
Text { text: "State: " + root.bridgeState; color: Color.mOnSurfaceVariant }
|
||||
Rectangle { Layout.fillWidth: true; height: 1; color: Color.mOutlineVariant }
|
||||
Text { text: root.detail; color: Color.mOnSurface; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||
Text { text: "Select a project explicitly before sending worktree-scoped actions."; color: Color.mOnSurfaceVariant; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
# Pi Status Bridge Noctalia plugin
|
||||
|
||||
This Noctalia v4 plugin is deliberately **presentation-only**. It renders
|
||||
state supplied through its `plugin:pi-status-bridge` IPC handler and opens its
|
||||
panel with `pluginApi.togglePanel`.
|
||||
This Noctalia v4 plugin is an aggregate-status **launcher**. It renders the
|
||||
workspace counts supplied through its `plugin:pi-status-bridge` IPC handler.
|
||||
Clicking its bar widget starts `pi-status-bridge.service` and toggles the
|
||||
standalone Pi Status UI. Set `PI_STATUS_UI_BINARY` to override the UI binary;
|
||||
the packaged local build path is the fallback.
|
||||
|
||||
Use `pi-status-bridge-client` to query the owner-only Unix bridge socket. A
|
||||
local integration script should translate responses and events into the plugin
|
||||
IPC handler's `setBridgeState(state, projectLabel, attentionCount, detail)`
|
||||
call. The plugin never starts Pi, opens TCP, chooses a worktree silently, or
|
||||
approves extension requests.
|
||||
call. The plugin never opens a Noctalia panel, sends prompts, switches
|
||||
sessions, chooses a worktree silently, or approves extension requests.
|
||||
|
||||
Install/register it through Noctalia's normal plugin workflow, then add its
|
||||
bar widget in Noctalia settings.
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
{
|
||||
"id": "pi-status-bridge",
|
||||
"name": "Pi Status Bridge",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"minNoctaliaVersion": "4.0.0",
|
||||
"description": "Presentation-only status bar and panel for a local Pi Status Bridge.",
|
||||
"description": "Aggregate Pi workspace status and launcher for the standalone Pi Status UI.",
|
||||
"entryPoints": {
|
||||
"main": "Main.qml",
|
||||
"barWidget": "BarWidget.qml",
|
||||
"panel": "Panel.qml"
|
||||
"barWidget": "BarWidget.qml"
|
||||
},
|
||||
"metadata": {
|
||||
"defaultSettings": {
|
||||
"bridgeState": "idle",
|
||||
"projectLabel": "Home",
|
||||
"projectLabel": "0 sessions",
|
||||
"attentionCount": 0,
|
||||
"detail": "Bridge not connected"
|
||||
}
|
||||
|
||||
@@ -10,75 +10,25 @@ local function ui_binary()
|
||||
return (noctalia.getenv("HOME") or "") .. "/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui"
|
||||
end
|
||||
|
||||
local function bridge_client_binary()
|
||||
local configured = noctalia.getenv("PI_STATUS_BRIDGE_CLIENT")
|
||||
if configured ~= nil and configured ~= "" then return shell_quote(configured) end
|
||||
return "pi-status-bridge-client"
|
||||
end
|
||||
|
||||
local function socket_path()
|
||||
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
|
||||
if runtime == nil or runtime == "" then return nil end
|
||||
return runtime .. "/pi-status-bridge/bridge.sock"
|
||||
end
|
||||
|
||||
local function current_agent_id()
|
||||
return noctalia.state.get("agent_id")
|
||||
end
|
||||
local bridge_start_in_flight = false
|
||||
local pending_ui_open = false
|
||||
|
||||
local function discover_home_agent(socket)
|
||||
if request_in_flight then return end
|
||||
request_in_flight = true
|
||||
noctalia.runAsync("pi-status-bridge-client --socket \"" .. socket .. "\" request '{\"op\":\"list_agents\"}'", function(result)
|
||||
request_in_flight = false
|
||||
if result.exitCode ~= 0 then return end
|
||||
local home = noctalia.getenv("HOME")
|
||||
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
|
||||
if worktree == home then
|
||||
noctalia.state.set("agent_id", id)
|
||||
noctalia.state.set("project_path", worktree)
|
||||
return
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function update()
|
||||
noctalia.setUpdateInterval(2000)
|
||||
local agent_id = current_agent_id()
|
||||
local socket = socket_path()
|
||||
if socket == nil then
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
barWidget.setText("Pi offline")
|
||||
barWidget.setTooltip("Bridge socket is unavailable")
|
||||
return
|
||||
end
|
||||
if agent_id == nil then
|
||||
discover_home_agent(socket)
|
||||
barWidget.setGlyph("terminal-2")
|
||||
barWidget.setText("Pi home")
|
||||
barWidget.setTooltip("Starting the default Home agent; click to open Pi Status UI")
|
||||
return
|
||||
end
|
||||
if request_in_flight then return end
|
||||
|
||||
request_in_flight = true
|
||||
local command = "pi-status-bridge-client --socket \"" .. socket
|
||||
.. "\" request '{\"op\":\"get_state\",\"agentId\":\"" .. agent_id .. "\"}'"
|
||||
noctalia.runAsync(command, function(result)
|
||||
request_in_flight = false
|
||||
if result.exitCode ~= 0 then
|
||||
noctalia.state.set("agent_id", nil)
|
||||
discover_home_agent(socket)
|
||||
barWidget.setGlyph("terminal-2")
|
||||
barWidget.setText("Pi reconnecting")
|
||||
barWidget.setTooltip("Refreshing the Home Pi agent")
|
||||
return
|
||||
end
|
||||
local streaming = string.find(result.stdout, "\"isStreaming\":true", 1, true) ~= nil
|
||||
barWidget.setGlyph(streaming and "terminal-2" or "terminal")
|
||||
barWidget.setText(streaming and "Pi working" or "Pi ready")
|
||||
barWidget.setTooltip(noctalia.state.get("project_path") or "Selected Pi folder")
|
||||
end)
|
||||
end
|
||||
|
||||
function onClick()
|
||||
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary())
|
||||
.. " --show >/tmp/pi-status-ui.log 2>&1"
|
||||
local function open_ui()
|
||||
local binary = shell_quote(ui_binary())
|
||||
local command = "if test -x " .. binary .. "; then setsid -f env TMPDIR=/tmp "
|
||||
.. binary .. " --toggle >/tmp/pi-status-ui.log 2>&1; else exit 1; fi"
|
||||
noctalia.runAsync(command, function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
@@ -87,3 +37,66 @@ function onClick()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function start_bridge(open_after_start)
|
||||
if open_after_start then pending_ui_open = true end
|
||||
if bridge_start_in_flight then return end
|
||||
bridge_start_in_flight = true
|
||||
barWidget.setGlyph("terminal-2")
|
||||
barWidget.setText("Pi starting")
|
||||
barWidget.setTooltip("Bridge is starting")
|
||||
noctalia.runAsync("systemctl --user start pi-status-bridge.service", function(result)
|
||||
bridge_start_in_flight = false
|
||||
if result.exitCode ~= 0 then
|
||||
pending_ui_open = false
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
barWidget.setText("Pi offline")
|
||||
barWidget.setTooltip("Could not start pi-status-bridge.service")
|
||||
return
|
||||
end
|
||||
if pending_ui_open then pending_ui_open = false; open_ui() end
|
||||
end)
|
||||
end
|
||||
|
||||
local function json_count(payload, key)
|
||||
local value = string.match(payload, '"' .. key .. '":(%d+)')
|
||||
return tonumber(value) or 0
|
||||
end
|
||||
|
||||
local function update_summary(payload)
|
||||
local open = json_count(payload, "openCount")
|
||||
local working = json_count(payload, "workingCount")
|
||||
local attention = json_count(payload, "attentionCount")
|
||||
local recovering = json_count(payload, "recoveringCount")
|
||||
local errors = json_count(payload, "errorCount")
|
||||
local glyph = errors > 0 and "alert-triangle" or attention > 0 and "circle-alert" or working > 0 and "terminal-2" or recovering > 0 and "refresh-cw" or "terminal"
|
||||
local text = errors > 0 and "Pi needs attention" or working > 0 and "Pi working" or recovering > 0 and "Pi recovering" or "Pi ready"
|
||||
local detail = open .. " open · " .. working .. " working · " .. attention .. " attention · " .. recovering .. " recovering · " .. errors .. " errors"
|
||||
barWidget.setGlyph(glyph)
|
||||
barWidget.setText(text)
|
||||
barWidget.setTooltip(detail .. "; click to open Pi Status UI")
|
||||
end
|
||||
|
||||
function update()
|
||||
noctalia.setUpdateInterval(2000)
|
||||
local socket = socket_path()
|
||||
if socket == nil then start_bridge(false); return end
|
||||
if request_in_flight then return end
|
||||
request_in_flight = true
|
||||
local command = bridge_client_binary() .. " --socket \"" .. socket .. "\" request '{\"op\":\"get_workspace_summary\"}'"
|
||||
noctalia.runAsync(command, function(result)
|
||||
request_in_flight = false
|
||||
if result.exitCode ~= 0 then
|
||||
start_bridge(false)
|
||||
barWidget.setGlyph("terminal-2")
|
||||
barWidget.setText("Pi reconnecting")
|
||||
barWidget.setTooltip("Refreshing Pi workspace summary")
|
||||
return
|
||||
end
|
||||
update_summary(result.stdout)
|
||||
end)
|
||||
end
|
||||
|
||||
function onClick()
|
||||
start_bridge(true)
|
||||
end
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
local agent_id = nil
|
||||
local agents = {}
|
||||
local messages = {}
|
||||
local folder_path = noctalia.getenv("HOME") or ""
|
||||
local composer = ""
|
||||
local extension_input = ""
|
||||
local pending_extension = nil
|
||||
local status = "Loading Pi agents…"
|
||||
local request_in_flight = false
|
||||
local stream_started = false
|
||||
local view = "output"
|
||||
local command_suggestions = {}
|
||||
local models = {}
|
||||
local model_options = {}
|
||||
local thinking_options = { "off", "minimal", "low", "medium", "high", "xhigh", "max" }
|
||||
local thinking_level = "medium"
|
||||
local session_summary = "Session loading…"
|
||||
|
||||
local function socket_path()
|
||||
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
|
||||
if runtime == nil or runtime == "" then return nil end
|
||||
return runtime .. "/pi-status-bridge/bridge.sock"
|
||||
end
|
||||
|
||||
local function shell_quote(value)
|
||||
return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'"
|
||||
end
|
||||
|
||||
local function request(op, agent, payload, callback)
|
||||
local socket = socket_path()
|
||||
if socket == nil then
|
||||
status = "Bridge socket is unavailable."
|
||||
render_panel()
|
||||
return
|
||||
end
|
||||
local body = { op = op }
|
||||
if agent ~= nil then body.agentId = agent end
|
||||
if payload ~= nil then body.payload = payload end
|
||||
local encoded, err = noctalia.json.encode(body)
|
||||
if encoded == nil then
|
||||
status = "Could not encode bridge request: " .. tostring(err)
|
||||
render_panel()
|
||||
return
|
||||
end
|
||||
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " request " .. shell_quote(encoded)
|
||||
noctalia.runAsync(command, function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
status = "Bridge request failed: " .. noctalia.string.trim(result.stderr)
|
||||
render_panel()
|
||||
return
|
||||
end
|
||||
local decoded, decode_error = noctalia.json.decode(result.stdout)
|
||||
if decoded == nil then
|
||||
status = "Invalid bridge response: " .. tostring(decode_error)
|
||||
render_panel()
|
||||
return
|
||||
end
|
||||
callback(decoded)
|
||||
end)
|
||||
end
|
||||
|
||||
local function message_text(message)
|
||||
if message == nil then return "" end
|
||||
if type(message.content) == "string" then return message.content end
|
||||
if type(message.content) ~= "table" then return "" end
|
||||
local parts = {}
|
||||
for _, block in ipairs(message.content) do
|
||||
if block.type == "text" and block.text ~= nil then table.insert(parts, block.text) end
|
||||
if block.type == "thinking" and block.thinking ~= nil then table.insert(parts, "Thinking: " .. block.thinking) end
|
||||
if block.type == "toolCall" and block.name ~= nil then table.insert(parts, "Tool: " .. block.name) end
|
||||
end
|
||||
return table.concat(parts, "\n")
|
||||
end
|
||||
|
||||
local function choose_home_agent()
|
||||
local home = noctalia.getenv("HOME")
|
||||
for _, agent in ipairs(agents) do
|
||||
if agent.worktreePath == home then return agent.id end
|
||||
end
|
||||
return agents[1] and agents[1].id or nil
|
||||
end
|
||||
|
||||
local function known_agent(id)
|
||||
for _, agent in ipairs(agents) do
|
||||
if agent.id == id then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function refresh_agents()
|
||||
request("list_agents", nil, nil, function(result)
|
||||
agents = result.agents or {}
|
||||
local remembered_id = agent_id or noctalia.state.get("agent_id")
|
||||
agent_id = known_agent(remembered_id) and remembered_id or choose_home_agent()
|
||||
if agent_id ~= nil then noctalia.state.set("agent_id", agent_id) end
|
||||
status = agent_id and "Pi ready" or "Add a folder to start Pi"
|
||||
render_panel()
|
||||
if agent_id ~= nil then
|
||||
refresh_transcript()
|
||||
refresh_commands()
|
||||
refresh_controls()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function refresh_controls()
|
||||
if agent_id == nil then return end
|
||||
request("get_state", agent_id, nil, function(result)
|
||||
local state = result.data or {}
|
||||
thinking_level = state.thinkingLevel or thinking_level
|
||||
session_summary = (state.sessionName or state.sessionId or "Session")
|
||||
.. " • " .. tostring(state.messageCount or 0) .. " messages"
|
||||
.. " • " .. tostring(state.pendingMessageCount or 0) .. " queued"
|
||||
status = state.isStreaming and "Pi working" or "Pi ready"
|
||||
request("get_available_models", agent_id, nil, function(models_result)
|
||||
models = (models_result.data and models_result.data.models) or {}
|
||||
model_options = {}
|
||||
for _, model in ipairs(models) do
|
||||
table.insert(model_options, (model.provider or "") .. "/" .. (model.id or model.name or "model"))
|
||||
end
|
||||
render_panel()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function refresh_commands()
|
||||
if agent_id == nil then return end
|
||||
request("get_commands", agent_id, nil, function(result)
|
||||
command_suggestions = {}
|
||||
for _, command in ipairs((result.data and result.data.commands) or {}) do
|
||||
if command.name ~= nil then table.insert(command_suggestions, "/" .. command.name) end
|
||||
end
|
||||
render_panel()
|
||||
end)
|
||||
end
|
||||
|
||||
function refresh_transcript()
|
||||
if agent_id == nil or request_in_flight then return end
|
||||
request_in_flight = true
|
||||
request("get_transcript", agent_id, nil, function(result)
|
||||
request_in_flight = false
|
||||
messages = result.data and result.data.messages or {}
|
||||
render_panel()
|
||||
end)
|
||||
end
|
||||
|
||||
local function start_stream()
|
||||
if stream_started or agent_id == nil then return end
|
||||
local socket = socket_path()
|
||||
if socket == nil then return end
|
||||
stream_started = true
|
||||
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " subscribe --agent " .. shell_quote(agent_id)
|
||||
noctalia.runStream(command, function(line)
|
||||
local event = noctalia.json.decode(line)
|
||||
if event == nil then return end
|
||||
if event.type == "extension_ui_request" then pending_extension = event.data and event.data.event end
|
||||
if event.type == "agent_state" then status = (event.data and event.data.state) or "Pi state changed" end
|
||||
if event.type == "queue" then status = "Pi queue updated" end
|
||||
if event.type == "stream" and event.data and event.data.event and event.data.event.type == "tool_execution_start" then
|
||||
status = "Tool: " .. tostring(event.data.event.toolName or "running")
|
||||
end
|
||||
if event.type == "message_end" or event.type == "agent_state" then refresh_transcript() end
|
||||
render_panel()
|
||||
end)
|
||||
end
|
||||
|
||||
local function agent_label(agent)
|
||||
return agent.worktreePath .. " — " .. agent.state
|
||||
end
|
||||
|
||||
function render_panel()
|
||||
local selected_path = noctalia.state.get("project_path") or "Home"
|
||||
local children = {
|
||||
ui.row({ gap = 8, justify = "space_between" }, {
|
||||
ui.column({ gap = 2 }, {
|
||||
ui.label({ text = "Pi", fontSize = 22, fontWeight = "bold" }),
|
||||
ui.label({ text = selected_path .. " — " .. status })
|
||||
}),
|
||||
ui.button({ text = view == "output" and "Settings" or "Output", glyph = "settings", variant = "ghost", onClick = "onToggleSettings" })
|
||||
})
|
||||
}
|
||||
|
||||
if view == "settings" then
|
||||
table.insert(children, ui.label({ text = "Folders and agents", fontWeight = "bold" }))
|
||||
local choices = {}
|
||||
for _, agent in ipairs(agents) do table.insert(choices, agent_label(agent)) end
|
||||
if #choices > 0 then table.insert(children, ui.select({ options = choices, onChange = "onSettingsAgentChange" })) end
|
||||
table.insert(children, ui.input({ value = folder_path, placeholder = "/absolute/path/to/project", onChange = "onFolderChanged", onSubmit = "onAddFolder" }))
|
||||
table.insert(children, ui.button({ text = "Add folder", glyph = "folder-plus", variant = "primary", onClick = "onAddFolder" }))
|
||||
table.insert(children, ui.label({ text = "Adding a folder explicitly starts or resumes its own Pi instance." }))
|
||||
table.insert(children, ui.label({ text = session_summary, maxLines = 2 }))
|
||||
table.insert(children, ui.label({ text = "Pi controls", fontWeight = "bold" }))
|
||||
if #model_options > 0 then table.insert(children, ui.select({ options = model_options, onChange = "onModelChange" })) end
|
||||
table.insert(children, ui.select({ options = thinking_options, onChange = "onThinkingChange" }))
|
||||
table.insert(children, ui.row({ gap = 8 }, {
|
||||
ui.button({ text = "Retry", onClick = "onRetry" }),
|
||||
ui.button({ text = "Restart Pi", variant = "destructive", onClick = "onRestart" })
|
||||
}))
|
||||
else
|
||||
table.insert(children, ui.label({ text = "Output", fontWeight = "bold" }))
|
||||
local transcript = {}
|
||||
for _, message in ipairs(messages) do
|
||||
local text = message_text(message)
|
||||
if text ~= "" then table.insert(transcript, ui.label({ text = (message.role or "message") .. ": " .. text, maxLines = 12 })) end
|
||||
end
|
||||
if #transcript == 0 then table.insert(transcript, ui.label({ text = "No messages yet." })) end
|
||||
table.insert(children, ui.scroll({ minHeight = 260, maxHeight = 520 }, transcript))
|
||||
table.insert(children, ui.input({ value = composer, placeholder = "Message Pi", onChange = "onComposerChanged", onSubmit = "onPrompt" }))
|
||||
if string.sub(composer, 1, 1) == "/" and #command_suggestions > 0 then
|
||||
table.insert(children, ui.select({ options = command_suggestions, onChange = "onCommandSuggestion" }))
|
||||
end
|
||||
table.insert(children, ui.row({ gap = 8 }, {
|
||||
ui.button({ text = "Send", variant = "primary", onClick = "onPrompt" }),
|
||||
ui.button({ text = "Abort", variant = "destructive", onClick = "onAbort" })
|
||||
}))
|
||||
end
|
||||
|
||||
if pending_extension ~= nil then
|
||||
local method = pending_extension.method or "input"
|
||||
table.insert(children, ui.label({ text = "Pi extension request: " .. (pending_extension.title or method), fontWeight = "bold" }))
|
||||
table.insert(children, ui.label({ text = pending_extension.message or "Choose a response." }))
|
||||
if method == "select" and pending_extension.options ~= nil then
|
||||
table.insert(children, ui.select({ options = pending_extension.options, onChange = "onExtensionSelect" }))
|
||||
elseif method == "confirm" then
|
||||
table.insert(children, ui.row({ gap = 8 }, {
|
||||
ui.button({ text = "Confirm", variant = "primary", onClick = "onExtensionConfirm" }),
|
||||
ui.button({ text = "Decline", onClick = "onExtensionDecline" })
|
||||
}))
|
||||
elseif method == "input" or method == "editor" then
|
||||
table.insert(children, ui.input({ value = extension_input, placeholder = method == "editor" and "Extension editor content" or "Extension response", onChange = "onExtensionInputChanged", onSubmit = "onExtensionSubmit" }))
|
||||
table.insert(children, ui.button({ text = "Submit", variant = "primary", onClick = "onExtensionSubmit" }))
|
||||
else
|
||||
table.insert(children, ui.label({ text = "This extension UI is unsupported in the panel. No response has been approved.", maxLines = 3 }))
|
||||
end
|
||||
table.insert(children, ui.button({ text = "Cancel request", variant = "ghost", onClick = "onExtensionCancel" }))
|
||||
end
|
||||
panel.render(ui.scroll({ padding = 20, gap = 12 }, children))
|
||||
end
|
||||
|
||||
function onOpen()
|
||||
panel.setWantsSecondTicks(true)
|
||||
refresh_agents()
|
||||
render_panel()
|
||||
end
|
||||
|
||||
function update()
|
||||
refresh_transcript()
|
||||
end
|
||||
|
||||
function onFolderChanged(value) folder_path = value end
|
||||
function onComposerChanged(value)
|
||||
composer = value
|
||||
if string.sub(composer, 1, 1) == "/" then render_panel() end
|
||||
end
|
||||
function onCommandSuggestion(index)
|
||||
local suggestion = command_suggestions[tonumber(index) + 1]
|
||||
if suggestion ~= nil then
|
||||
composer = suggestion .. " "
|
||||
render_panel()
|
||||
end
|
||||
end
|
||||
function onExtensionInputChanged(value) extension_input = value end
|
||||
|
||||
function onSettingsAgentChange(index)
|
||||
local agent = agents[tonumber(index) + 1]
|
||||
if agent == nil then return end
|
||||
agent_id = agent.id
|
||||
noctalia.state.set("agent_id", agent_id)
|
||||
noctalia.state.set("project_path", agent.worktreePath)
|
||||
stream_started = false
|
||||
start_stream()
|
||||
refresh_transcript()
|
||||
end
|
||||
|
||||
function onModelChange(index)
|
||||
local model = models[tonumber(index) + 1]
|
||||
if model == nil or agent_id == nil then return end
|
||||
request("set_model", agent_id, { provider = model.provider, modelId = model.id }, function(_) refresh_controls() end)
|
||||
end
|
||||
|
||||
function onThinkingChange(index)
|
||||
local level = thinking_options[tonumber(index) + 1]
|
||||
if level == nil or agent_id == nil then return end
|
||||
request("set_thinking_level", agent_id, { level = level }, function(_) refresh_controls() end)
|
||||
end
|
||||
|
||||
function onRetry()
|
||||
if agent_id ~= nil then request("retry", agent_id, nil, function(_) refresh_agents() end) end
|
||||
end
|
||||
|
||||
function onRestart()
|
||||
if agent_id ~= nil then request("restart", agent_id, nil, function(_) refresh_agents() end) end
|
||||
end
|
||||
|
||||
function onAddFolder()
|
||||
if string.sub(folder_path, 1, 1) ~= "/" then status = "Enter an absolute folder path."; render_panel(); return end
|
||||
request("select_agent", nil, { worktreePath = folder_path }, function(result)
|
||||
agent_id = result.agent and result.agent.id or nil
|
||||
if agent_id == nil then status = "Bridge did not return an agent."; render_panel(); return end
|
||||
noctalia.state.set("agent_id", agent_id)
|
||||
noctalia.state.set("project_path", folder_path)
|
||||
status = "Pi ready for " .. folder_path
|
||||
stream_started = false
|
||||
start_stream()
|
||||
refresh_agents()
|
||||
end)
|
||||
end
|
||||
|
||||
local function submit_composer()
|
||||
if agent_id == nil then status = "Choose a folder first."; render_panel(); return end
|
||||
if composer == "" then return end
|
||||
request("submit_prompt", agent_id, { message = composer }, function(_)
|
||||
composer = ""
|
||||
status = "Prompt queued by Pi"
|
||||
refresh_transcript()
|
||||
end)
|
||||
end
|
||||
|
||||
function onPrompt() submit_composer() end
|
||||
function onAbort() if agent_id ~= nil then request("abort", agent_id, nil, function(_) status = "Aborted"; render_panel() end) end end
|
||||
|
||||
local function send_extension_response(response)
|
||||
if pending_extension == nil or agent_id == nil then return end
|
||||
request("extension_response", agent_id, { requestId = pending_extension.id, response = response }, function(_)
|
||||
pending_extension = nil
|
||||
extension_input = ""
|
||||
render_panel()
|
||||
end)
|
||||
end
|
||||
|
||||
function onExtensionSubmit()
|
||||
send_extension_response({ value = extension_input })
|
||||
end
|
||||
|
||||
function onExtensionSelect(index)
|
||||
if pending_extension == nil or pending_extension.options == nil then return end
|
||||
local option = pending_extension.options[tonumber(index) + 1]
|
||||
if option ~= nil then send_extension_response({ value = option }) end
|
||||
end
|
||||
|
||||
function onExtensionConfirm()
|
||||
send_extension_response({ confirmed = true })
|
||||
end
|
||||
|
||||
function onExtensionDecline()
|
||||
send_extension_response({ confirmed = false })
|
||||
end
|
||||
|
||||
function onExtensionCancel()
|
||||
if pending_extension == nil or agent_id == nil then return end
|
||||
request("extension_response", agent_id, { requestId = pending_extension.id, response = { cancelled = true } }, function(_)
|
||||
pending_extension = nil
|
||||
extension_input = ""
|
||||
render_panel()
|
||||
end)
|
||||
end
|
||||
|
||||
function onClosePicker()
|
||||
panel.close()
|
||||
end
|
||||
+20
-2
@@ -1,7 +1,19 @@
|
||||
{
|
||||
"name": "pi-status-bridge",
|
||||
"version": "0.1.0",
|
||||
"description": "Local Unix-socket bridge and desktop UI for persistent Pi coding-agent sessions",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@git.commumedia.org:2222/alex/pi-gui.git"
|
||||
},
|
||||
"keywords": [
|
||||
"pi",
|
||||
"coding-agent",
|
||||
"tauri",
|
||||
"noctalia"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pi-status-bridge": "src/bridge/cli.js",
|
||||
@@ -12,8 +24,14 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js && npm run test:ui",
|
||||
"test": "node --test test/*.test.js && npm --prefix ui test && npm run test:ui",
|
||||
"test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml",
|
||||
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build"
|
||||
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/workspace-store.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build",
|
||||
"bridge:service:install": "mkdir -p \"$HOME/.config/systemd/user\" && cp systemd/pi-status-bridge.service \"$HOME/.config/systemd/user/\" && systemctl --user daemon-reload && systemctl --user enable --now pi-status-bridge.service",
|
||||
"bridge:service:start": "systemctl --user start pi-status-bridge.service",
|
||||
"bridge:service:restart": "systemctl --user restart pi-status-bridge.service",
|
||||
"bridge:service:stop": "systemctl --user stop pi-status-bridge.service",
|
||||
"bridge:service:status": "systemctl --user status pi-status-bridge.service",
|
||||
"bridge:service:logs": "journalctl --user -u pi-status-bridge.service -f"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Impact analysis
|
||||
|
||||
## Target
|
||||
|
||||
Abort and ordinary follow-up submission flow across the Bridge registry, Tauri
|
||||
wrapper, workspace reducer, and conversation composer.
|
||||
|
||||
## Dependents (6)
|
||||
|
||||
- `src/bridge/service.js`: exposes registry routing through the local socket.
|
||||
- `ui/src-tauri/src/bridge.rs`: proxies submit/abort to the Bridge.
|
||||
- `ui/src-tauri/src/lib.rs`: exposes Tauri commands to the frontend.
|
||||
- `ui/src/App.tsx`: submits prompts and coordinates abort UI state.
|
||||
- `ui/src/workspace/reducer.ts`: tracks runtime activity, queues, and pending
|
||||
submissions.
|
||||
- `ui/src/components/ConversationWorkspace.tsx`: renders composer actions and
|
||||
pending submission labels.
|
||||
|
||||
## Affected stories
|
||||
|
||||
No release plan or epic capsules exist.
|
||||
|
||||
## Test coverage
|
||||
|
||||
- `test/agent-registry.test.js`: existing busy submission routes to Pi
|
||||
`follow_up`.
|
||||
- `ui/src/workspace/reducer.test.ts`: existing optimistic-message
|
||||
reconciliation.
|
||||
- `ui/src/components/components.test.tsx`: existing composer component wiring.
|
||||
- Gap: abort priority/feedback, returned delivery mode, duplicate follow-ups,
|
||||
and authoritative queue counts are untested.
|
||||
|
||||
## Risk: Medium
|
||||
|
||||
This changes command scheduling and message acknowledgement at the Pi RPC
|
||||
boundary; an incorrect transition could lose or mislabel user follow-ups.
|
||||
|
||||
## Recommended action
|
||||
|
||||
Use one normal submit action with Bridge-selected delivery, make abort
|
||||
out-of-band and visibly pending, then add regression tests for queue and
|
||||
message reconciliation.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Multi-session workspace architecture
|
||||
|
||||
## Status
|
||||
|
||||
This is the authoritative contract for the bridge-managed multi-session workspace. It supersedes the one-live-agent-per-worktree assumption for new desktop APIs while preserving the legacy v1 surface.
|
||||
|
||||
## Identity
|
||||
|
||||
| Identity | Lifetime | Owner | Purpose |
|
||||
| --- | --- | --- | --- |
|
||||
| canonical `worktreePath` | durable | filesystem/bridge | directory group |
|
||||
| `runtimeId` | durable while its tab is desired open | bridge manifest | open-session slot and UI reconciliation |
|
||||
| Pi `sessionId` + `sessionPath` | durable history | Pi | saved conversation |
|
||||
| `agentId` | one child in one daemon generation | bridge | legacy command and event routing |
|
||||
| `bridgeInstanceId` | one daemon generation | bridge | invalidates stale cursors and agent IDs |
|
||||
|
||||
The UI never persists `agentId`. One canonical session file has at most one live runtime lease. Opening an already-open session returns its existing runtime.
|
||||
|
||||
## Runtime lifecycle
|
||||
|
||||
```text
|
||||
starting -> idle <-> streaming
|
||||
| |
|
||||
+-> recovering -> idle | failed
|
||||
+-> attention/error
|
||||
any open state -> closing -> stopped
|
||||
```
|
||||
|
||||
Each runtime owns one Pi RPC child, command serialization queue, recovery supervisor, event buffer, extension-attention cache, and summary. Several runtimes may share a worktree and session directory, but never a session file. Same-worktree concurrency is allowed and directory summaries expose open/working counts so clients can warn about filesystem and Git collisions.
|
||||
|
||||
Commands and lifecycle mutations are serialized per runtime. Close removes desired-open intent before stopping the child. A working runtime is aborted first, then stopped with a bounded wait and forced termination fallback. Closing keeps the JSONL history. Forgetting a directory is refused while any of its runtimes remain open.
|
||||
|
||||
## Persistence ownership
|
||||
|
||||
The bridge stores `bridge-workspace-v2.json` beneath the owner-only persistent `sessionRoot`. It is versioned, mode `0600`, and replaced atomically through a temporary file and rename. It records desired-open runtime IDs, canonical worktrees, and Pi session identity. Daemon shutdown preserves records; explicit close removes one.
|
||||
|
||||
On the first v2 migration only the legacy Home runtime is opened. After that migration an explicitly closed Home runtime remains closed and zero live Pi runtimes is valid. Restore launches desired-open runtimes with concurrency two. Six or more open runtimes produces a resource warning but there is no hard cap.
|
||||
|
||||
Missing, moved, corrupt, cross-directory, or duplicate session records are never replaced with a new session. They are retained/reported as failed dormant runtime intent or as a manifest issue. A corrupt whole manifest is reported and starts no runtimes rather than guessing.
|
||||
|
||||
The Tauri UI owns presentation persistence (order, selection, drafts, scroll and last-seen state) in owner-only app data. It must not persist transcripts, extension response values, request IDs, or arbitrary extension payloads.
|
||||
|
||||
Legacy `bridge-agent.json` remains the compatibility default-session pointer. New runtime state is authoritative in the workspace manifest.
|
||||
|
||||
## Additive v1 compatibility API
|
||||
|
||||
Existing `list_agents`, `select_agent`, `list_sessions`, `new_session`, `switch_session`, agent commands, and agent-scoped `subscribe` retain their meanings. `select_agent` resolves a compatible default runtime for that directory. The desktop must not implement tabs with legacy session switching.
|
||||
|
||||
New operations:
|
||||
|
||||
- `get_workspace`
|
||||
- `get_workspace_summary`
|
||||
- `create_session_runtime`
|
||||
- `open_session_runtime`
|
||||
- `close_session_runtime`
|
||||
- `list_directory_sessions`
|
||||
- `get_session_runtime_snapshot`
|
||||
- `subscribe_workspace`
|
||||
|
||||
All cursors are non-negative integers.
|
||||
|
||||
## Workspace event and replay contract
|
||||
|
||||
`subscribe_workspace` is the preferred aggregate subscription. Every event has a global order and contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v1",
|
||||
"bridgeInstanceId": "daemon UUID",
|
||||
"seq": 42,
|
||||
"type": "runtime_event",
|
||||
"agentId": "ephemeral when live",
|
||||
"data": {
|
||||
"runtimeId": "durable slot UUID",
|
||||
"worktreePath": "/canonical/path",
|
||||
"sessionId": "Pi UUID when known",
|
||||
"eventType": "tool",
|
||||
"eventData": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replay responses include `bridgeInstanceId`, `firstAvailableSeq`, `latestSeq`, `truncated`, and `events`. Clients reload the workspace and runtime snapshots when the daemon ID changes or replay is truncated. Listener installation precedes replay delivery, preserving replay-then-live ordering.
|
||||
|
||||
## Snapshot and summary contract
|
||||
|
||||
`get_session_runtime_snapshot` addresses one `runtimeId` and returns runtime summary, state, transcript, statistics, commands, models, cached actionable extension requests, `bridgeInstanceId`, and the latest global sequence. A failed dormant runtime returns its identity/error summary without silently starting another conversation.
|
||||
|
||||
Runtime labels prioritize explicit Pi session name, first user prompt, then `New session`. Runtime summaries cache state, queue count, active tool, attention, error/recovery state and last activity. Workspace/directory summaries aggregate open, working, recovering, attention and error counts; Noctalia consumes only the aggregate summary and launches the desktop UI.
|
||||
|
||||
## Safety and recovery
|
||||
|
||||
- The bridge remains the sole Pi process/session/recovery owner.
|
||||
- Each runtime recovery restarts with its exact leased session path.
|
||||
- Session identity is refreshed at launch, after legacy session mutation, and after authoritative idle/settled transitions so the first prompt-created file is persisted.
|
||||
- Background runtimes continue receiving events and extension attention.
|
||||
- Pending extension requests are scoped to a runtime; another tab cannot answer them.
|
||||
- A daemon restart changes `bridgeInstanceId` and all `agentId` values while keeping `runtimeId` stable.
|
||||
- Restore failures surface explicitly. The bridge never substitutes a different session.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
bug_id: BUG-20260727-widget-toggle
|
||||
status: fixed
|
||||
severity: medium
|
||||
scope: noctalia-v5-launcher
|
||||
title: Repeated status-bar clicks cannot hide the Pi Status UI
|
||||
---
|
||||
|
||||
# BUG-20260727-widget-toggle: Repeated status-bar clicks cannot hide the Pi Status UI
|
||||
|
||||
## Problem
|
||||
|
||||
A Noctalia v5 status-bar click launches or focuses Pi Status UI. A second click must hide the same window, so a window that has moved off-screen can be dismissed and restored with a later click. Instead, every click only shows and focuses the existing window.
|
||||
|
||||
Reproduce on Noctalia v5:
|
||||
|
||||
1. Start Pi Status UI and the v5 status-bar widget.
|
||||
2. Click the widget once.
|
||||
3. Click it again while the Pi Status UI window is visible.
|
||||
4. Observe that the window remains visible instead of hiding.
|
||||
|
||||
Security impact: LOW. No security exploit path was identified; the defect affects local window visibility only.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Reproduce
|
||||
|
||||
The v5 launcher invokes Pi Status UI with `--show` on every widget click. The desktop app's single-instance handler receives the second launch and always shows/focuses the existing window.
|
||||
|
||||
### Isolate
|
||||
|
||||
The v4 adapter uses Noctalia's native panel toggle and is not part of this failure. The v5 launcher and the desktop app's single-instance handler form the complete click-to-window path.
|
||||
|
||||
### Hypothesize
|
||||
|
||||
1. The launcher requests show rather than toggle. Falsification: require `--toggle` in the v5 launcher test.
|
||||
2. The desktop app ignores second-instance arguments. Falsification: define and test a pure window-action selector for visible and hidden states.
|
||||
|
||||
### Verify
|
||||
|
||||
The launcher contains `--show`, and the single-instance handler discards its argument list before unconditionally calling show/focus. This confirms both hypotheses and the root cause.
|
||||
|
||||
### Follow-up launch regression
|
||||
|
||||
After the generated Tauri target directory was cleaned, the configured default release binary did not exist. The launcher detached through `setsid`, so Noctalia saw a successful launcher process even though `env` recorded that the binary was missing. Rebuilding the release binary restores the launcher, and the launcher now checks that its configured binary is executable before detaching.
|
||||
|
||||
## Fix Approach
|
||||
|
||||
Replace the v5 launch request with `--toggle`. Route `--toggle` in the desktop single-instance handler to hide a visible window or show/focus a hidden window. Preserve `--show` as an explicit show/focus operation for future callers.
|
||||
|
||||
Risk level: Low. The change is confined to local launcher/window behavior and does not affect bridge protocol, worktree routing, or approvals.
|
||||
|
||||
## TDD Fix Plan
|
||||
|
||||
1. **RED**: Require the v5 launcher test to use `--toggle` and reject `--show`.
|
||||
**GREEN**: Change the launch argument from `--show` to `--toggle`.
|
||||
**verify**: `node --test test/noctalia-v5-plugin.test.js`
|
||||
|
||||
2. **RED**: Add Rust tests that characterize `--toggle` for visible and hidden windows and preserve explicit `--show` behavior.
|
||||
**GREEN**: Add a pure action selector and invoke it from the single-instance callback.
|
||||
**verify**: `cargo test --manifest-path ui/src-tauri/Cargo.toml`
|
||||
|
||||
**REFACTOR**: Keep argument interpretation independent from Tauri window calls so its behavior remains unit-testable.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] A v5 widget click launches Pi Status UI with `--toggle`.
|
||||
- [x] A second click hides a visible Pi Status UI window.
|
||||
- [x] A later click shows and focuses the hidden window.
|
||||
- [x] Explicit `--show` still shows and focuses the window.
|
||||
- [x] Existing bridge, plugin, and UI tests pass.
|
||||
- [x] The launcher rejects a missing or non-executable configured binary before detaching.
|
||||
|
||||
## Resolution
|
||||
|
||||
The v5 widget now launches the desktop app with `--toggle`. The desktop app converts second-instance arguments and current window visibility into an explicit hide or show/focus action. The launcher also verifies its executable before detaching, and the expected release binary has been rebuilt. Focused Node and Rust characterization tests, the full `npm test` suite, and `npm run check` pass.
|
||||
@@ -0,0 +1,9 @@
|
||||
bugs:
|
||||
- bug_id: BUG-20260727-widget-toggle
|
||||
status: fixed
|
||||
date: 2026-07-27
|
||||
severity: medium
|
||||
priority: high
|
||||
scope: noctalia-v5-launcher
|
||||
summary: Repeated status-bar clicks cannot hide the Pi Status UI
|
||||
file: specs/bugs/BUG-20260727-widget-toggle.md
|
||||
+1280
-227
File diff suppressed because it is too large
Load Diff
@@ -2,25 +2,13 @@ import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { PI_RPC_COMMANDS } from "./pi-rpc-command-spec.js";
|
||||
|
||||
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
|
||||
export const COMPACT_COMMAND_TIMEOUT_MS = 5 * 60_000;
|
||||
export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024;
|
||||
|
||||
const supportedCommands = new Set([
|
||||
"prompt",
|
||||
"steer",
|
||||
"follow_up",
|
||||
"abort",
|
||||
"get_state",
|
||||
"get_session_stats",
|
||||
"new_session",
|
||||
"switch_session",
|
||||
"get_messages",
|
||||
"get_available_models",
|
||||
"get_commands",
|
||||
"set_model",
|
||||
"set_thinking_level",
|
||||
]);
|
||||
const supportedCommands = new Set(PI_RPC_COMMANDS);
|
||||
|
||||
export class PiRpcError extends Error {
|
||||
constructor(code, message) {
|
||||
@@ -40,9 +28,9 @@ function assertAbsolutePath(value, field) {
|
||||
}
|
||||
}
|
||||
|
||||
function emitSafely(callback, value) {
|
||||
function emitSafely(callback, ...values) {
|
||||
try {
|
||||
callback(value);
|
||||
callback(...values);
|
||||
} catch {
|
||||
// Adapter observers must not interrupt the RPC reader.
|
||||
}
|
||||
@@ -126,6 +114,7 @@ export function startPiRpcAdapter({
|
||||
onEvent = () => {},
|
||||
onError = () => {},
|
||||
commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
|
||||
compactCommandTimeoutMs = COMPACT_COMMAND_TIMEOUT_MS,
|
||||
maxFrameBytes = MAX_PI_RPC_FRAME_BYTES,
|
||||
}) {
|
||||
assertAbsolutePath(cwd, "cwd");
|
||||
@@ -157,6 +146,7 @@ export function startPiRpcAdapter({
|
||||
|
||||
let sequence = 0;
|
||||
let stdoutBuffer = "";
|
||||
let discardingOversizedFrame = false;
|
||||
let closed = false;
|
||||
let intentionalStop = false;
|
||||
const decoder = new StringDecoder("utf8");
|
||||
@@ -166,7 +156,7 @@ export function startPiRpcAdapter({
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const reportError = (error) => emitSafely(onError, error);
|
||||
const reportError = (error, metadata) => emitSafely(onError, error, metadata);
|
||||
const rejectPending = (error) => {
|
||||
for (const entry of pending.values()) {
|
||||
clearTimeout(entry.timeout);
|
||||
@@ -233,37 +223,58 @@ export function startPiRpcAdapter({
|
||||
if (frame.type === "response") handleResponse(frame);
|
||||
else handleEvent(frame);
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
if (closed) return;
|
||||
stdoutBuffer += decoder.write(chunk);
|
||||
let newlineIndex;
|
||||
while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex);
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||||
handleLine(line);
|
||||
}
|
||||
if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) {
|
||||
const reportOversizedFrame = () =>
|
||||
reportError(
|
||||
new PiRpcError(
|
||||
"frame_too_large",
|
||||
`Pi RPC frame exceeds ${maxFrameBytes} bytes`,
|
||||
),
|
||||
{ terminal: false },
|
||||
);
|
||||
stdoutBuffer = "";
|
||||
const consumeStdout = (text) => {
|
||||
let remaining = text;
|
||||
if (discardingOversizedFrame) {
|
||||
const newlineIndex = remaining.indexOf("\n");
|
||||
if (newlineIndex === -1) return;
|
||||
discardingOversizedFrame = false;
|
||||
remaining = remaining.slice(newlineIndex + 1);
|
||||
}
|
||||
stdoutBuffer += remaining;
|
||||
let newlineIndex;
|
||||
while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
|
||||
const line = stdoutBuffer.slice(0, newlineIndex);
|
||||
stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
|
||||
if (Buffer.byteLength(line, "utf8") > maxFrameBytes) {
|
||||
reportOversizedFrame();
|
||||
continue;
|
||||
}
|
||||
handleLine(line);
|
||||
}
|
||||
if (Buffer.byteLength(stdoutBuffer, "utf8") > maxFrameBytes) {
|
||||
reportOversizedFrame();
|
||||
stdoutBuffer = "";
|
||||
discardingOversizedFrame = true;
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
if (!closed) consumeStdout(decoder.write(chunk));
|
||||
});
|
||||
child.stdout.on("end", () => {
|
||||
if (closed) return;
|
||||
const tail = stdoutBuffer + decoder.end();
|
||||
stdoutBuffer = "";
|
||||
if (tail.length > 0)
|
||||
consumeStdout(decoder.end());
|
||||
if (discardingOversizedFrame) {
|
||||
discardingOversizedFrame = false;
|
||||
return;
|
||||
}
|
||||
if (stdoutBuffer.length > 0)
|
||||
reportError(
|
||||
new PiRpcError(
|
||||
"unterminated_frame",
|
||||
"Pi RPC stdout ended without an LF-terminated frame",
|
||||
),
|
||||
);
|
||||
stdoutBuffer = "";
|
||||
});
|
||||
child.stderr.on("data", () => {});
|
||||
child.stderr.on("error", (error) =>
|
||||
@@ -289,7 +300,7 @@ export function startPiRpcAdapter({
|
||||
get sequence() {
|
||||
return sequence;
|
||||
},
|
||||
send(commandInput) {
|
||||
send(commandInput, { timeoutMs: requestedTimeoutMs } = {}) {
|
||||
if (!isRecord(commandInput) || typeof commandInput.type !== "string") {
|
||||
return Promise.reject(
|
||||
new PiRpcError(
|
||||
@@ -314,8 +325,24 @@ export function startPiRpcAdapter({
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
requestedTimeoutMs !== undefined &&
|
||||
(!Number.isSafeInteger(requestedTimeoutMs) || requestedTimeoutMs <= 0)
|
||||
) {
|
||||
return Promise.reject(
|
||||
new PiRpcError(
|
||||
"invalid_timeout",
|
||||
"Pi RPC command timeout must be a positive safe integer",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const id = `bridge-${randomUUID()}`;
|
||||
const timeoutMs =
|
||||
requestedTimeoutMs ??
|
||||
(commandInput.type === "compact"
|
||||
? compactCommandTimeoutMs
|
||||
: commandTimeoutMs);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
@@ -325,7 +352,7 @@ export function startPiRpcAdapter({
|
||||
`Pi RPC command timed out: ${commandInput.type}`,
|
||||
),
|
||||
);
|
||||
}, commandTimeoutMs);
|
||||
}, timeoutMs);
|
||||
timeout.unref?.();
|
||||
pending.set(id, { resolve, reject, timeout });
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const fields = {
|
||||
prompt: ["message", "images", "streamingBehavior"],
|
||||
steer: ["message", "images"],
|
||||
follow_up: ["message", "images"],
|
||||
abort: [],
|
||||
new_session: ["parentSession"],
|
||||
get_state: [],
|
||||
get_messages: [],
|
||||
set_model: ["provider", "modelId"],
|
||||
cycle_model: [],
|
||||
get_available_models: [],
|
||||
set_thinking_level: ["level"],
|
||||
cycle_thinking_level: [],
|
||||
set_steering_mode: ["mode"],
|
||||
set_follow_up_mode: ["mode"],
|
||||
compact: ["customInstructions"],
|
||||
set_auto_compaction: ["enabled"],
|
||||
set_auto_retry: ["enabled"],
|
||||
abort_retry: [],
|
||||
bash: ["command"],
|
||||
abort_bash: [],
|
||||
get_session_stats: [],
|
||||
export_html: ["outputPath"],
|
||||
switch_session: ["sessionPath"],
|
||||
fork: ["entryId"],
|
||||
clone: [],
|
||||
get_fork_messages: [],
|
||||
get_entries: ["since"],
|
||||
get_tree: [],
|
||||
get_last_assistant_text: [],
|
||||
set_session_name: ["name"],
|
||||
get_commands: [],
|
||||
};
|
||||
|
||||
export const PI_RPC_COMMANDS = Object.freeze(Object.keys(fields));
|
||||
|
||||
export function validatePiRpcCommand(command, input) {
|
||||
if (!Object.hasOwn(fields, command))
|
||||
throw new TypeError(`unsupported Pi RPC command: ${command}`);
|
||||
if (input === undefined) return {};
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input))
|
||||
throw new TypeError("Pi RPC command input must be an object");
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!fields[command].includes(key))
|
||||
throw new TypeError(`unsupported Pi RPC field: ${command}.${key}`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
+66
-1
@@ -1,5 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { createAgentRegistry, UnknownAgentError } from "./agent-registry.js";
|
||||
import {
|
||||
createAgentRegistry,
|
||||
UnknownAgentError,
|
||||
UnknownRuntimeError,
|
||||
} from "./agent-registry.js";
|
||||
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
|
||||
import { ProtocolError } from "../protocol/index.js";
|
||||
import { createRuntimePaths, startBridgeServer } from "./runtime.js";
|
||||
@@ -30,6 +34,47 @@ export async function startBridgeService({
|
||||
return {
|
||||
agent: await registry.selectWorktree(request.payload.worktreePath),
|
||||
};
|
||||
case "forget_directory":
|
||||
return registry.forgetDirectory(request.payload.worktreePath);
|
||||
case "get_workspace":
|
||||
return registry.getWorkspace();
|
||||
case "get_workspace_summary":
|
||||
return registry.getWorkspaceSummary();
|
||||
case "get_model_catalog":
|
||||
return { models: await registry.getModelCatalog() };
|
||||
case "create_session_runtime":
|
||||
return {
|
||||
runtime: await registry.createSessionRuntime(
|
||||
request.payload.worktreePath,
|
||||
),
|
||||
};
|
||||
case "create_quick_runtime":
|
||||
return {
|
||||
runtime: await registry.createQuickRuntime(
|
||||
request.payload.worktreePath,
|
||||
),
|
||||
};
|
||||
case "open_session_runtime":
|
||||
return {
|
||||
runtime: await registry.openSessionRuntime(
|
||||
request.payload.worktreePath,
|
||||
request.payload.sessionPath,
|
||||
),
|
||||
};
|
||||
case "close_session_runtime":
|
||||
return registry.closeSessionRuntime(request.payload.runtimeId);
|
||||
case "close_quick_runtime":
|
||||
return registry.closeQuickRuntime(request.payload.runtimeId);
|
||||
case "list_directory_sessions":
|
||||
return {
|
||||
sessions: await registry.listDirectorySessions(
|
||||
request.payload.worktreePath,
|
||||
),
|
||||
};
|
||||
case "get_session_runtime_snapshot":
|
||||
return registry.getSessionRuntimeSnapshot(request.payload.runtimeId);
|
||||
case "subscribe_workspace":
|
||||
return registry.workspaceEventsAfter(request.payload.cursor ?? 0);
|
||||
case "subscribe":
|
||||
return {
|
||||
events: registry.eventsAfter(
|
||||
@@ -41,6 +86,8 @@ export async function startBridgeService({
|
||||
return { agent: await registry.retry(request.agentId) };
|
||||
case "restart":
|
||||
return { agent: await registry.restart(request.agentId) };
|
||||
case "renew_session":
|
||||
return { agent: await registry.renewSession(request.agentId) };
|
||||
case "list_sessions":
|
||||
return { sessions: await registry.listSessions(request.agentId) };
|
||||
default:
|
||||
@@ -49,6 +96,8 @@ export async function startBridgeService({
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownAgentError)
|
||||
throw new ProtocolError("unknown_agent", error.message);
|
||||
if (error instanceof UnknownRuntimeError)
|
||||
throw new ProtocolError("unknown_runtime", error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -57,6 +106,22 @@ export async function startBridgeService({
|
||||
runtimeDir,
|
||||
handleRequest: dispatch,
|
||||
subscribe: (request, notify) => {
|
||||
if (request.op === "subscribe_workspace") {
|
||||
const subscription = registry.subscribeWorkspace(
|
||||
request.payload.cursor ?? 0,
|
||||
notify,
|
||||
);
|
||||
return {
|
||||
result: {
|
||||
bridgeInstanceId: subscription.bridgeInstanceId,
|
||||
firstAvailableSeq: subscription.firstAvailableSeq,
|
||||
latestSeq: subscription.latestSeq,
|
||||
truncated: subscription.truncated,
|
||||
events: subscription.events,
|
||||
},
|
||||
unsubscribe: subscription.unsubscribe,
|
||||
};
|
||||
}
|
||||
const subscription = registry.subscribe(
|
||||
request.agentId,
|
||||
request.payload.cursor ?? 0,
|
||||
|
||||
@@ -88,7 +88,10 @@ export async function startUnixSocketServer({
|
||||
const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
|
||||
try {
|
||||
const request = parseRequestFrame(normalized, maxFrameBytes);
|
||||
if (request.op === "subscribe" && subscribe) {
|
||||
if (
|
||||
(request.op === "subscribe" || request.op === "subscribe_workspace") &&
|
||||
subscribe
|
||||
) {
|
||||
await handleSubscription(request);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const WORKSPACE_VERSION = 2;
|
||||
const WORKSPACE_FILE = "bridge-workspace-v2.json";
|
||||
const DIRECTORY_MODE = 0o700;
|
||||
const FILE_MODE = 0o600;
|
||||
|
||||
function validRuntime(record) {
|
||||
return (
|
||||
record &&
|
||||
typeof record === "object" &&
|
||||
typeof record.runtimeId === "string" &&
|
||||
record.runtimeId.length > 0 &&
|
||||
typeof record.worktreePath === "string" &&
|
||||
path.isAbsolute(record.worktreePath) &&
|
||||
(record.sessionPath === undefined ||
|
||||
(typeof record.sessionPath === "string" &&
|
||||
path.isAbsolute(record.sessionPath))) &&
|
||||
(record.sessionId === undefined || typeof record.sessionId === "string") &&
|
||||
(record.legacyDefault === undefined ||
|
||||
typeof record.legacyDefault === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeManifest(value) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
value.version !== WORKSPACE_VERSION ||
|
||||
!Array.isArray(value.runtimes) ||
|
||||
!value.runtimes.every(validRuntime) ||
|
||||
(value.directories !== undefined &&
|
||||
(!Array.isArray(value.directories) ||
|
||||
!value.directories.every(
|
||||
(directory) =>
|
||||
typeof directory === "string" && path.isAbsolute(directory),
|
||||
)))
|
||||
)
|
||||
throw new Error("workspace manifest has an unsupported or invalid shape");
|
||||
const runtimeIds = new Set();
|
||||
const sessionPaths = new Set();
|
||||
const directories = new Set(value.directories ?? []);
|
||||
if (directories.size !== (value.directories ?? []).length)
|
||||
throw new Error("workspace manifest repeats a directory");
|
||||
for (const runtime of value.runtimes) directories.add(runtime.worktreePath);
|
||||
for (const runtime of value.runtimes) {
|
||||
if (runtimeIds.has(runtime.runtimeId))
|
||||
throw new Error(
|
||||
`workspace manifest repeats runtime ${runtime.runtimeId}`,
|
||||
);
|
||||
runtimeIds.add(runtime.runtimeId);
|
||||
if (runtime.sessionPath) {
|
||||
if (sessionPaths.has(runtime.sessionPath))
|
||||
throw new Error(
|
||||
`workspace manifest repeats session ${runtime.sessionPath}`,
|
||||
);
|
||||
sessionPaths.add(runtime.sessionPath);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: WORKSPACE_VERSION,
|
||||
migrated: value.migrated === true,
|
||||
directories: [...directories].sort(),
|
||||
runtimes: value.runtimes.map((runtime) => ({ ...runtime })),
|
||||
};
|
||||
}
|
||||
|
||||
export function createWorkspaceStore(sessionRoot) {
|
||||
if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot))
|
||||
throw new TypeError("sessionRoot must be an absolute path");
|
||||
const filePath = path.join(sessionRoot, WORKSPACE_FILE);
|
||||
let writeQueue = Promise.resolve();
|
||||
|
||||
async function load() {
|
||||
await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE });
|
||||
await chmod(sessionRoot, DIRECTORY_MODE);
|
||||
try {
|
||||
return {
|
||||
manifest: normalizeManifest(
|
||||
JSON.parse(await readFile(filePath, "utf8")),
|
||||
),
|
||||
migrating: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
return {
|
||||
manifest: {
|
||||
version: WORKSPACE_VERSION,
|
||||
migrated: false,
|
||||
directories: [],
|
||||
runtimes: [],
|
||||
},
|
||||
migrating: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
manifest: {
|
||||
version: WORKSPACE_VERSION,
|
||||
migrated: true,
|
||||
directories: [],
|
||||
runtimes: [],
|
||||
},
|
||||
migrating: false,
|
||||
issue: {
|
||||
code: "invalid_workspace_manifest",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function save(manifest) {
|
||||
const normalized = normalizeManifest({ ...manifest, migrated: true });
|
||||
const operation = writeQueue
|
||||
.catch(() => {})
|
||||
.then(async () => {
|
||||
await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE });
|
||||
await chmod(sessionRoot, DIRECTORY_MODE);
|
||||
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await writeFile(
|
||||
temporaryPath,
|
||||
`${JSON.stringify(normalized, null, 2)}\n`,
|
||||
{
|
||||
encoding: "utf8",
|
||||
mode: FILE_MODE,
|
||||
},
|
||||
);
|
||||
await chmod(temporaryPath, FILE_MODE);
|
||||
await rename(temporaryPath, filePath);
|
||||
await chmod(filePath, FILE_MODE);
|
||||
} finally {
|
||||
await rm(temporaryPath, { force: true }).catch(() => {});
|
||||
}
|
||||
});
|
||||
writeQueue = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { filePath, load, save };
|
||||
}
|
||||
|
||||
export const workspaceManifestVersion = WORKSPACE_VERSION;
|
||||
export const workspaceManifestFile = WORKSPACE_FILE;
|
||||
@@ -7,29 +7,16 @@ export async function publishNoctaliaState(
|
||||
state,
|
||||
{ execute = async (command, args) => execFileAsync(command, args) } = {},
|
||||
) {
|
||||
if (!state || typeof state !== "object")
|
||||
throw new TypeError("state is required");
|
||||
const { state: bridgeState, projectLabel, attentionCount, detail } = state;
|
||||
if (!state || typeof state !== "object") throw new TypeError("state is required");
|
||||
const { state: bridgeState, projectLabel, attentionCount, detail, openCount = 0, workingCount = 0, recoveringCount = 0, errorCount = 0 } = state;
|
||||
if (
|
||||
typeof bridgeState !== "string" ||
|
||||
typeof projectLabel !== "string" ||
|
||||
!Number.isSafeInteger(attentionCount) ||
|
||||
typeof bridgeState !== "string" || typeof projectLabel !== "string" ||
|
||||
![attentionCount, openCount, workingCount, recoveringCount, errorCount].every(Number.isSafeInteger) ||
|
||||
typeof detail !== "string"
|
||||
) {
|
||||
throw new TypeError(
|
||||
"state must contain string state/projectLabel/detail and integer attentionCount",
|
||||
);
|
||||
}
|
||||
) throw new TypeError("state must contain presentation strings and integer counts");
|
||||
await execute("qs", [
|
||||
"-c",
|
||||
"noctalia-shell",
|
||||
"ipc",
|
||||
"call",
|
||||
"plugin:pi-status-bridge",
|
||||
"setBridgeState",
|
||||
bridgeState,
|
||||
projectLabel,
|
||||
String(attentionCount),
|
||||
detail,
|
||||
"-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState",
|
||||
bridgeState, projectLabel, String(attentionCount), detail, String(openCount),
|
||||
String(workingCount), String(recoveringCount), String(errorCount),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -10,25 +10,23 @@ function flag(args, name) {
|
||||
|
||||
async function run() {
|
||||
const args = process.argv.slice(2);
|
||||
const socketPath =
|
||||
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
||||
const socketPath = flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
|
||||
const agentId = flag(args, "--agent");
|
||||
if (!socketPath || !agentId)
|
||||
throw new Error(
|
||||
"Usage: pi-status-bridge-noctalia-relay --socket <path> --agent <id>",
|
||||
);
|
||||
if (!socketPath)
|
||||
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path> [--agent <id>]");
|
||||
const client = await connectLocalClient({ socketPath });
|
||||
let agent;
|
||||
if (agentId) {
|
||||
const agents = await client.request("list_agents");
|
||||
const agent = agents.agents.find((candidate) => candidate.id === agentId);
|
||||
agent = agents.agents.find((candidate) => candidate.id === agentId);
|
||||
if (!agent) throw new Error(`unknown agent: ${agentId}`);
|
||||
}
|
||||
const relay = createNoctaliaStateRelay({
|
||||
client,
|
||||
agent,
|
||||
onState: (state) => {
|
||||
void publishNoctaliaState(state).catch((error) =>
|
||||
...(agent ? { agent } : {}),
|
||||
onState: (state) => void publishNoctaliaState(state).catch((error) =>
|
||||
process.stderr.write(`${error.message}\n`),
|
||||
);
|
||||
},
|
||||
),
|
||||
});
|
||||
await relay.start();
|
||||
await new Promise((resolve) => {
|
||||
@@ -41,8 +39,6 @@ async function run() {
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`,
|
||||
);
|
||||
process.stderr.write(`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -1,58 +1,102 @@
|
||||
import path from "node:path";
|
||||
|
||||
function detailFor(state) {
|
||||
function detailForSummary(summary) {
|
||||
return [
|
||||
`${summary.openCount} open`,
|
||||
`${summary.workingCount} working`,
|
||||
`${summary.attentionCount} attention`,
|
||||
`${summary.recoveringCount} recovering`,
|
||||
`${summary.errorCount} errors`,
|
||||
].join(" · ");
|
||||
}
|
||||
|
||||
function stateForSummary(summary) {
|
||||
if (summary.errorCount) return "error";
|
||||
if (summary.recoveringCount) return "recovering";
|
||||
if (summary.workingCount) return "streaming";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
function summaryState(summary) {
|
||||
return {
|
||||
state: stateForSummary(summary),
|
||||
projectLabel: `${summary.openCount} session${summary.openCount === 1 ? "" : "s"}`,
|
||||
attentionCount: summary.attentionCount,
|
||||
detail: detailForSummary(summary),
|
||||
openCount: summary.openCount,
|
||||
workingCount: summary.workingCount,
|
||||
recoveringCount: summary.recoveringCount,
|
||||
errorCount: summary.errorCount,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyDetail(state) {
|
||||
switch (state) {
|
||||
case "streaming":
|
||||
return "Streaming";
|
||||
case "recovering":
|
||||
return "Recovering Pi session";
|
||||
case "failed":
|
||||
return "Recovery failed";
|
||||
case "error":
|
||||
return "Bridge error";
|
||||
default:
|
||||
return "Idle";
|
||||
case "streaming": return "Streaming";
|
||||
case "recovering": return "Recovering Pi session";
|
||||
case "failed": return "Recovery failed";
|
||||
case "error": return "Bridge error";
|
||||
default: return "Idle";
|
||||
}
|
||||
}
|
||||
|
||||
export function createNoctaliaStateRelay({ client, agent, onState }) {
|
||||
if (
|
||||
!client ||
|
||||
typeof client.request !== "function" ||
|
||||
typeof client.subscribe !== "function"
|
||||
)
|
||||
throw new TypeError("client must support request and subscribe");
|
||||
if (!agent?.id || !agent?.worktreePath)
|
||||
/**
|
||||
* Relays workspace-wide presentation state to Noctalia. Passing `agent` keeps
|
||||
* the former agent-scoped behavior for external compatibility callers only.
|
||||
*/
|
||||
export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_000 }) {
|
||||
if (!client || typeof client.request !== "function")
|
||||
throw new TypeError("client must support request");
|
||||
if (agent && (!agent.id || !agent.worktreePath))
|
||||
throw new TypeError("agent id and worktreePath are required");
|
||||
if (typeof onState !== "function")
|
||||
throw new TypeError("onState must be a function");
|
||||
if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250))
|
||||
throw new TypeError("pollMs must be an integer of at least 250ms");
|
||||
if (typeof onState !== "function") throw new TypeError("onState must be a function");
|
||||
|
||||
let state = "idle";
|
||||
let attentionCount = 0;
|
||||
let unsubscribe = () => {};
|
||||
const projectLabel = path.basename(agent.worktreePath) || agent.worktreePath;
|
||||
const publish = () =>
|
||||
onState({ state, projectLabel, attentionCount, detail: detailFor(state) });
|
||||
const handleEvent = (event) => {
|
||||
if (event.type === "agent_state" && typeof event.data?.state === "string")
|
||||
state = event.data.state;
|
||||
if (event.type === "queue")
|
||||
attentionCount =
|
||||
(event.data?.event?.steering?.length ?? 0) +
|
||||
(event.data?.event?.followUp?.length ?? 0);
|
||||
if (event.type === "extension_ui_request")
|
||||
attentionCount = Math.max(attentionCount, 1);
|
||||
publish();
|
||||
};
|
||||
let timer;
|
||||
let stopped = false;
|
||||
const publishLegacy = (state, attentionCount) => onState({
|
||||
state,
|
||||
projectLabel: path.basename(agent.worktreePath) || agent.worktreePath,
|
||||
attentionCount,
|
||||
detail: legacyDetail(state),
|
||||
openCount: 1,
|
||||
workingCount: state === "streaming" ? 1 : 0,
|
||||
recoveringCount: state === "recovering" ? 1 : 0,
|
||||
errorCount: ["error", "failed"].includes(state) ? 1 : 0,
|
||||
});
|
||||
|
||||
return {
|
||||
async start() {
|
||||
if (agent) {
|
||||
if (typeof client.subscribe !== "function")
|
||||
throw new TypeError("legacy agent relay requires client.subscribe");
|
||||
const response = await client.request("get_state", { agentId: agent.id });
|
||||
state = response?.data?.isStreaming ? "streaming" : "idle";
|
||||
publish();
|
||||
unsubscribe = await client.subscribe(agent.id, 0, handleEvent);
|
||||
let state = response?.data?.isStreaming ? "streaming" : "idle";
|
||||
let attentionCount = 0;
|
||||
publishLegacy(state, attentionCount);
|
||||
unsubscribe = await client.subscribe(agent.id, 0, (event) => {
|
||||
if (event.type === "agent_state" && typeof event.data?.state === "string") state = event.data.state;
|
||||
if (event.type === "queue") attentionCount = (event.data?.event?.steering?.length ?? 0) + (event.data?.event?.followUp?.length ?? 0);
|
||||
if (event.type === "extension_ui_request") attentionCount = Math.max(attentionCount, 1);
|
||||
publishLegacy(state, attentionCount);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const summary = await client.request("get_workspace_summary");
|
||||
if (!stopped) onState(summaryState(summary));
|
||||
} finally {
|
||||
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
|
||||
}
|
||||
};
|
||||
await refresh();
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
unsubscribe = () => {};
|
||||
},
|
||||
|
||||
+93
-11
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { validatePiRpcCommand } from "../bridge/pi-rpc-command-spec.js";
|
||||
|
||||
export const PROTOCOL_VERSION = "v1";
|
||||
export const MAX_FRAME_BYTES = 64 * 1024;
|
||||
@@ -7,6 +8,18 @@ const requestOperations = new Map([
|
||||
["list_agents", { agent: false, payload: "none" }],
|
||||
["list_directories", { agent: false, payload: "none" }],
|
||||
["select_agent", { agent: false, payload: "worktree" }],
|
||||
["forget_directory", { agent: false, payload: "worktree" }],
|
||||
["get_workspace", { agent: false, payload: "none" }],
|
||||
["get_workspace_summary", { agent: false, payload: "none" }],
|
||||
["get_model_catalog", { agent: false, payload: "none" }],
|
||||
["create_session_runtime", { agent: false, payload: "worktree" }],
|
||||
["create_quick_runtime", { agent: false, payload: "worktree" }],
|
||||
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
|
||||
["close_session_runtime", { agent: false, payload: "runtime" }],
|
||||
["close_quick_runtime", { agent: false, payload: "runtime" }],
|
||||
["list_directory_sessions", { agent: false, payload: "worktree" }],
|
||||
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
|
||||
["subscribe_workspace", { agent: false, payload: "cursor" }],
|
||||
["get_state", { agent: true, payload: "none" }],
|
||||
["get_session_stats", { agent: true, payload: "none" }],
|
||||
["list_sessions", { agent: true, payload: "none" }],
|
||||
@@ -22,10 +35,14 @@ const requestOperations = new Map([
|
||||
["extension_response", { agent: true, payload: "extensionResponse" }],
|
||||
["retry", { agent: true, payload: "none" }],
|
||||
["restart", { agent: true, payload: "none" }],
|
||||
["renew_session", { agent: true, payload: "none" }],
|
||||
["get_available_models", { agent: true, payload: "none" }],
|
||||
["get_commands", { agent: true, payload: "none" }],
|
||||
["set_session_name", { agent: true, payload: "sessionName" }],
|
||||
["compact", { agent: true, payload: "compact" }],
|
||||
["set_model", { agent: true, payload: "model" }],
|
||||
["set_thinking_level", { agent: true, payload: "thinking" }],
|
||||
["pi_rpc_command", { agent: true, payload: "piRpcCommand" }],
|
||||
]);
|
||||
|
||||
const eventTypes = new Set([
|
||||
@@ -89,20 +106,17 @@ function assertString(value, field, { maxLength = 4096, pattern } = {}) {
|
||||
);
|
||||
}
|
||||
if (pattern && !pattern.test(value))
|
||||
throw new ProtocolError(
|
||||
"invalid_message",
|
||||
`${field} has an invalid format`,
|
||||
);
|
||||
throw new ProtocolError("invalid_message", `${field} has an invalid format`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertOptionalCursor(value) {
|
||||
if (value === undefined) return undefined;
|
||||
if (Number.isSafeInteger(value) && value >= 0) return value;
|
||||
return assertString(value, "payload.cursor", {
|
||||
maxLength: 256,
|
||||
pattern: idPattern,
|
||||
});
|
||||
throw new ProtocolError(
|
||||
"invalid_message",
|
||||
"payload.cursor must be a non-negative integer",
|
||||
);
|
||||
}
|
||||
|
||||
function validatePayload(kind, value) {
|
||||
@@ -114,6 +128,7 @@ function validatePayload(kind, value) {
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (kind === "compact" && value === undefined) return undefined;
|
||||
|
||||
const payload = assertRecord(value, "payload");
|
||||
switch (kind) {
|
||||
@@ -136,9 +151,7 @@ function validatePayload(kind, value) {
|
||||
const sessionPath = assertString(
|
||||
payload.sessionPath,
|
||||
"payload.sessionPath",
|
||||
{
|
||||
maxLength: 4096,
|
||||
},
|
||||
{ maxLength: 4096 },
|
||||
);
|
||||
if (!path.isAbsolute(sessionPath))
|
||||
throw new ProtocolError(
|
||||
@@ -147,6 +160,38 @@ function validatePayload(kind, value) {
|
||||
);
|
||||
return { sessionPath };
|
||||
}
|
||||
case "worktreeSession": {
|
||||
assertAllowedKeys(
|
||||
payload,
|
||||
new Set(["worktreePath", "sessionPath"]),
|
||||
"payload",
|
||||
);
|
||||
const worktreePath = assertString(
|
||||
payload.worktreePath,
|
||||
"payload.worktreePath",
|
||||
{ maxLength: 4096 },
|
||||
);
|
||||
const sessionPath = assertString(
|
||||
payload.sessionPath,
|
||||
"payload.sessionPath",
|
||||
{ maxLength: 4096 },
|
||||
);
|
||||
if (!path.isAbsolute(worktreePath) || !path.isAbsolute(sessionPath))
|
||||
throw new ProtocolError(
|
||||
"invalid_message",
|
||||
"payload worktree and session paths must be absolute",
|
||||
);
|
||||
return { worktreePath, sessionPath };
|
||||
}
|
||||
case "runtime": {
|
||||
assertAllowedKeys(payload, new Set(["runtimeId"]), "payload");
|
||||
return {
|
||||
runtimeId: assertString(payload.runtimeId, "payload.runtimeId", {
|
||||
maxLength: 128,
|
||||
pattern: idPattern,
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "cursor": {
|
||||
assertAllowedKeys(payload, new Set(["cursor"]), "payload");
|
||||
return {
|
||||
@@ -184,6 +229,32 @@ function validatePayload(kind, value) {
|
||||
response,
|
||||
};
|
||||
}
|
||||
case "sessionName": {
|
||||
assertAllowedKeys(payload, new Set(["name"]), "payload");
|
||||
const name = assertString(payload.name, "payload.name", {
|
||||
maxLength: 4096,
|
||||
}).trim();
|
||||
if (!name)
|
||||
throw new ProtocolError(
|
||||
"invalid_message",
|
||||
"payload.name must not be blank",
|
||||
);
|
||||
return { name };
|
||||
}
|
||||
case "compact": {
|
||||
assertAllowedKeys(payload, new Set(["customInstructions"]), "payload");
|
||||
const customInstructions = assertString(
|
||||
payload.customInstructions,
|
||||
"payload.customInstructions",
|
||||
{ maxLength: 32 * 1024 },
|
||||
).trim();
|
||||
if (!customInstructions)
|
||||
throw new ProtocolError(
|
||||
"invalid_message",
|
||||
"payload.customInstructions must not be blank",
|
||||
);
|
||||
return { customInstructions };
|
||||
}
|
||||
case "model": {
|
||||
assertAllowedKeys(payload, new Set(["provider", "modelId"]), "payload");
|
||||
return {
|
||||
@@ -207,6 +278,17 @@ function validatePayload(kind, value) {
|
||||
);
|
||||
return { level };
|
||||
}
|
||||
case "piRpcCommand": {
|
||||
assertAllowedKeys(payload, new Set(["command", "input"]), "payload");
|
||||
const command = assertString(payload.command, "payload.command", {
|
||||
maxLength: 64,
|
||||
});
|
||||
try {
|
||||
return { command, input: validatePiRpcCommand(command, payload.input) };
|
||||
} catch (error) {
|
||||
throw new ProtocolError("invalid_message", error.message);
|
||||
}
|
||||
}
|
||||
default:
|
||||
throw new ProtocolError("invalid_message", "unsupported payload shape");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/home/alex/.npm-global/bin/pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
|
||||
ExecStart=/usr/bin/env pi-status-bridge --session-root %h/.local/state/pi-status-bridge/sessions
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
|
||||
import {
|
||||
createAgentRegistry,
|
||||
sessionDirectoryPath,
|
||||
} from "../src/bridge/agent-registry.js";
|
||||
|
||||
test("recovers an exited agent with its last Pi session reference", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-recovery-"));
|
||||
const home = join(root, "home");
|
||||
const sessionRoot = join(root, "sessions");
|
||||
await mkdir(home);
|
||||
const sessionFile = join(sessionRoot, "pi-session.jsonl");
|
||||
const sessionDir = sessionDirectoryPath(sessionRoot, home);
|
||||
await mkdir(sessionDir, { recursive: true });
|
||||
const sessionFile = join(sessionDir, "pi-session.jsonl");
|
||||
await writeFile(
|
||||
sessionFile,
|
||||
`${JSON.stringify({ type: "session", version: 3, id: "recovery", cwd: home })}\n`,
|
||||
);
|
||||
const calls = [];
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: home,
|
||||
|
||||
+344
-4
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -20,10 +20,12 @@ function createAdapterFactory() {
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
sent: [],
|
||||
sentWithOptions: [],
|
||||
extensionResponses: [],
|
||||
stopped: false,
|
||||
send(command) {
|
||||
send(command, options) {
|
||||
this.sent.push(command);
|
||||
this.sentWithOptions.push({ command, options });
|
||||
return Promise.resolve({
|
||||
type: "response",
|
||||
command: command.type,
|
||||
@@ -53,6 +55,10 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
||||
});
|
||||
|
||||
const home = await registry.start();
|
||||
assert.deepEqual(fixture.calls[0].adapter.sentWithOptions[0], {
|
||||
command: { type: "get_state" },
|
||||
options: { timeoutMs: 90_000 },
|
||||
});
|
||||
assert.equal(registry.listAgents().length, 1);
|
||||
assert.equal(home.worktreePath, worktrees.home);
|
||||
|
||||
@@ -72,6 +78,327 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("sends abort without waiting behind a blocked prompt", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
const agent = await registry.start();
|
||||
const adapter = fixture.calls[0].adapter;
|
||||
const send = adapter.send.bind(adapter);
|
||||
let releasePrompt;
|
||||
adapter.send = (command) => {
|
||||
if (command.type !== "prompt") return send(command);
|
||||
adapter.sent.push(command);
|
||||
return new Promise((resolve) => {
|
||||
releasePrompt = () =>
|
||||
resolve({ type: "response", command: "prompt", success: true });
|
||||
});
|
||||
};
|
||||
const prompt = registry.route(agent.id, "prompt", { message: "Work" });
|
||||
while (!releasePrompt) await new Promise((resolve) => setImmediate(resolve));
|
||||
const abort = registry.route(agent.id, "abort");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(adapter.sent.at(-1).type, "abort");
|
||||
releasePrompt();
|
||||
await Promise.all([prompt, abort]);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("keeps a runtime healthy after a non-terminal adapter diagnostic", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
|
||||
fixture.calls[0].options.onError(
|
||||
{ code: "frame_too_large", message: "Pi RPC frame exceeds 32 bytes" },
|
||||
{ terminal: false },
|
||||
);
|
||||
|
||||
const runtime = registry.getWorkspace().directories[0].runtimes[0];
|
||||
assert.equal(runtime.state, "idle");
|
||||
assert.equal(runtime.attention, false);
|
||||
assert.equal(runtime.error, undefined);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("stops agents created during shutdown and rejects new selections", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
let releaseFeatureCreation;
|
||||
const featureCreation = new Promise((resolve) => {
|
||||
releaseFeatureCreation = resolve;
|
||||
});
|
||||
let featureCreationStarted;
|
||||
const featureStarted = new Promise((resolve) => {
|
||||
featureCreationStarted = resolve;
|
||||
});
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: (options) => {
|
||||
const adapter = fixture.startAdapter(options);
|
||||
if (options.cwd === worktrees.feature) {
|
||||
const send = adapter.send.bind(adapter);
|
||||
adapter.send = (command) => {
|
||||
if (command.type !== "get_state") return send(command);
|
||||
featureCreationStarted();
|
||||
return featureCreation.then(() => send(command));
|
||||
};
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
await registry.start();
|
||||
const selecting = registry.selectWorktree(worktrees.feature);
|
||||
await featureStarted;
|
||||
|
||||
const stopping = registry.stop();
|
||||
await assert.rejects(
|
||||
registry.selectWorktree(worktrees.feature),
|
||||
/registry is stopping/,
|
||||
);
|
||||
releaseFeatureCreation();
|
||||
await selecting;
|
||||
await stopping;
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
test("drains a forget still resolving its canonical path during shutdown", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const featureAlias = join(worktrees.root, "feature-alias");
|
||||
await symlink(worktrees.feature, featureAlias);
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
await registry.start();
|
||||
await registry.selectWorktree(worktrees.feature);
|
||||
const featureAdapter = fixture.calls[1].adapter;
|
||||
const stop = featureAdapter.stop.bind(featureAdapter);
|
||||
let stopCalls = 0;
|
||||
featureAdapter.stop = async () => {
|
||||
stopCalls += 1;
|
||||
await stop();
|
||||
};
|
||||
|
||||
const selectedFeature = registry
|
||||
.listAgents()
|
||||
.find((agent) => agent.worktreePath === worktrees.feature);
|
||||
await registry.closeSessionRuntime(selectedFeature.runtimeId);
|
||||
const forgetting = registry.forgetDirectory(featureAlias);
|
||||
const stopping = registry.stop();
|
||||
await Promise.all([forgetting, stopping]);
|
||||
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(
|
||||
registry
|
||||
.listAgents()
|
||||
.some((agent) => agent.worktreePath === worktrees.feature),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("forgets a directory without deleting its resumable session history", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: fixture.startAdapter,
|
||||
});
|
||||
const home = await registry.start();
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const sessionPath = join(feature.sessionDir, "resumable.jsonl");
|
||||
const sessionContent = `${JSON.stringify({
|
||||
type: "session",
|
||||
id: "resumable",
|
||||
timestamp: "2025-01-01T00:00:00.000Z",
|
||||
cwd: worktrees.feature,
|
||||
})}\n`;
|
||||
await writeFile(sessionPath, sessionContent);
|
||||
await writeFile(
|
||||
join(feature.sessionDir, "bridge-agent.json"),
|
||||
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
await registry.closeSessionRuntime(feature.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
assert.deepEqual(registry.listAgents(), [home]);
|
||||
assert.deepEqual(await registry.listDirectories(), [
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
isHome: true,
|
||||
agentId: home.id,
|
||||
},
|
||||
]);
|
||||
assert.equal(await readFile(sessionPath, "utf8"), sessionContent);
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.home),
|
||||
/cannot forget the home directory/,
|
||||
);
|
||||
|
||||
const reopened = await registry.selectWorktree(worktrees.feature);
|
||||
assert.deepEqual(
|
||||
(await registry.listSessions(reopened.id)).map(({ id }) => id),
|
||||
["resumable"],
|
||||
);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("coordinates forgetting with directory creation and active commands", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
let releaseFeatureCreation;
|
||||
const featureCreation = new Promise((resolve) => {
|
||||
releaseFeatureCreation = resolve;
|
||||
});
|
||||
let featureCreationStarted;
|
||||
const featureStarted = new Promise((resolve) => {
|
||||
featureCreationStarted = resolve;
|
||||
});
|
||||
const registry = createAgentRegistry({
|
||||
homeWorktree: worktrees.home,
|
||||
sessionRoot: worktrees.sessionRoot,
|
||||
startAdapter: (options) => {
|
||||
const adapter = fixture.startAdapter(options);
|
||||
if (options.cwd === worktrees.feature) {
|
||||
const send = adapter.send.bind(adapter);
|
||||
adapter.send = (command) => {
|
||||
if (command.type !== "get_state") return send(command);
|
||||
featureCreationStarted();
|
||||
return featureCreation.then(() => send(command));
|
||||
};
|
||||
}
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
await registry.start();
|
||||
|
||||
const selecting = registry.selectWorktree(worktrees.feature);
|
||||
await featureStarted;
|
||||
releaseFeatureCreation();
|
||||
const selectedDuringCreation = await selecting;
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
await registry.closeSessionRuntime(selectedDuringCreation.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
assert.equal(fixture.calls[1].adapter.stopped, true);
|
||||
assert.equal(
|
||||
(await registry.listDirectories()).some(
|
||||
(directory) => directory.worktreePath === worktrees.feature,
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
await registry.selectWorktree(worktrees.feature);
|
||||
const concurrentAdapter = fixture.calls[2].adapter;
|
||||
const stop = concurrentAdapter.stop.bind(concurrentAdapter);
|
||||
let releaseStop;
|
||||
const stopGate = new Promise((resolve) => {
|
||||
releaseStop = resolve;
|
||||
});
|
||||
let stopCalls = 0;
|
||||
concurrentAdapter.stop = async () => {
|
||||
stopCalls += 1;
|
||||
await stopGate;
|
||||
await stop();
|
||||
};
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
const closing = registry.closeSessionRuntime(
|
||||
registry
|
||||
.listAgents()
|
||||
.find((agent) => agent.worktreePath === worktrees.feature).runtimeId,
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
releaseStop();
|
||||
await closing;
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(registry.listAgents().length, 1);
|
||||
|
||||
const feature = await registry.selectWorktree(worktrees.feature);
|
||||
const { adapter, options } = fixture.calls[3];
|
||||
const send = adapter.send.bind(adapter);
|
||||
let releasePrompt;
|
||||
const promptResponse = new Promise((resolve) => {
|
||||
releasePrompt = resolve;
|
||||
});
|
||||
adapter.send = (command) =>
|
||||
command.type === "prompt" ? promptResponse : send(command);
|
||||
const prompt = registry.route(feature.id, "prompt", {
|
||||
message: "Keep working",
|
||||
});
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
assert.equal(adapter.stopped, false);
|
||||
releasePrompt({ type: "response", success: true });
|
||||
await prompt;
|
||||
options.onEvent({ type: "agent_state", data: { state: "idle" } });
|
||||
await registry.closeSessionRuntime(feature.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
assert.equal(adapter.stopped, true);
|
||||
|
||||
const failedFeature = await registry.selectWorktree(worktrees.feature);
|
||||
const failedFixture = fixture.calls[4];
|
||||
let rejectPrompt;
|
||||
const failedPromptResponse = new Promise((_resolve, reject) => {
|
||||
rejectPrompt = reject;
|
||||
});
|
||||
const failedSend = failedFixture.adapter.send.bind(failedFixture.adapter);
|
||||
failedFixture.adapter.send = (command) =>
|
||||
command.type === "prompt" ? failedPromptResponse : failedSend(command);
|
||||
const failedPrompt = registry.route(failedFeature.id, "prompt", {
|
||||
message: "Start before the response fails",
|
||||
});
|
||||
const failedPromptAssertion = assert.rejects(failedPrompt, /send failed/);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "streaming" },
|
||||
});
|
||||
rejectPrompt(new Error("send failed"));
|
||||
await failedPromptAssertion;
|
||||
assert.equal(
|
||||
registry.listAgents().find((entry) => entry.id === failedFeature.id).state,
|
||||
"streaming",
|
||||
);
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/runtimes are open/,
|
||||
);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
await registry.closeSessionRuntime(failedFeature.runtimeId);
|
||||
await registry.forgetDirectory(worktrees.feature);
|
||||
await registry.stop();
|
||||
});
|
||||
|
||||
test("catalogues managed directories and exposes only their sessions", async () => {
|
||||
const worktrees = await createWorktrees();
|
||||
const fixture = createAdapterFactory();
|
||||
@@ -142,6 +469,7 @@ test("catalogues managed directories and exposes only their sessions", async ()
|
||||
}),
|
||||
/ENOENT|belong/,
|
||||
);
|
||||
await registry.closeSessionRuntime(feature.runtimeId);
|
||||
await registry.stop();
|
||||
|
||||
const restored = createAgentRegistry({
|
||||
@@ -151,10 +479,15 @@ test("catalogues managed directories and exposes only their sessions", async ()
|
||||
});
|
||||
await restored.start();
|
||||
assert.deepEqual(await restored.listDirectories(), [
|
||||
{ worktreePath: worktrees.feature, state: "inactive" },
|
||||
{
|
||||
worktreePath: worktrees.feature,
|
||||
state: "inactive",
|
||||
isHome: false,
|
||||
},
|
||||
{
|
||||
worktreePath: worktrees.home,
|
||||
state: "idle",
|
||||
isHome: true,
|
||||
agentId: restored.listAgents()[0].id,
|
||||
},
|
||||
]);
|
||||
@@ -192,20 +525,27 @@ test("routes commands by explicit agent ID and replays only events after the cur
|
||||
command: "get_commands",
|
||||
success: true,
|
||||
});
|
||||
await registry.route(agent.id, "set_session_name", { name: "Release UI" });
|
||||
await registry.route(agent.id, "compact", {
|
||||
customInstructions: "Preserve implementation details",
|
||||
});
|
||||
assert.deepEqual(adapter.sent, [
|
||||
{ type: "get_state" },
|
||||
{ type: "prompt", message: "Stay in this worktree" },
|
||||
{ type: "get_session_stats" },
|
||||
{ type: "get_commands" },
|
||||
{ type: "set_session_name", name: "Release UI" },
|
||||
{ type: "compact", customInstructions: "Preserve implementation details" },
|
||||
]);
|
||||
|
||||
options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "streaming" },
|
||||
});
|
||||
await registry.route(agent.id, "submit_prompt", {
|
||||
const followUp = await registry.route(agent.id, "submit_prompt", {
|
||||
message: "After this turn",
|
||||
});
|
||||
assert.equal(followUp.delivery, "follow_up");
|
||||
assert.deepEqual(adapter.sent.at(-1), {
|
||||
type: "follow_up",
|
||||
message: "After this turn",
|
||||
|
||||
@@ -18,11 +18,16 @@ function connect(socketPath) {
|
||||
function request(socket, frame) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const onError = (error) => {
|
||||
socket.off("data", onData);
|
||||
reject(error);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
buffer += chunk;
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline === -1) return;
|
||||
socket.off("data", onData);
|
||||
socket.off("error", onError);
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
@@ -30,7 +35,7 @@ function request(socket, frame) {
|
||||
}
|
||||
};
|
||||
socket.on("data", onData);
|
||||
socket.once("error", reject);
|
||||
socket.once("error", onError);
|
||||
socket.write(`${JSON.stringify(frame)}\n`);
|
||||
});
|
||||
}
|
||||
@@ -89,6 +94,32 @@ test("starts the home agent and dispatches local protocol requests to the select
|
||||
payload: { worktreePath: feature },
|
||||
});
|
||||
const featureAgent = selected.result.agent;
|
||||
const workspace = await request(socket, {
|
||||
version: "v1",
|
||||
id: "workspace-1",
|
||||
op: "get_workspace",
|
||||
});
|
||||
const summary = await request(socket, {
|
||||
version: "v1",
|
||||
id: "summary-1",
|
||||
op: "get_workspace_summary",
|
||||
});
|
||||
assert.equal(typeof workspace.result.bridgeInstanceId, "string");
|
||||
assert.equal(workspace.result.directories.length, 2);
|
||||
assert.equal(summary.result.openCount, 2);
|
||||
assert.equal(summary.result.directoryCount, 2);
|
||||
assert.equal(summary.result.resourceWarning, false);
|
||||
const snapshot = await request(socket, {
|
||||
version: "v1",
|
||||
id: "snapshot-1",
|
||||
op: "get_session_runtime_snapshot",
|
||||
payload: { runtimeId: featureAgent.runtimeId },
|
||||
});
|
||||
assert.equal(snapshot.result.runtime.runtimeId, featureAgent.runtimeId);
|
||||
assert.equal(
|
||||
snapshot.result.bridgeInstanceId,
|
||||
workspace.result.bridgeInstanceId,
|
||||
);
|
||||
const prompt = await request(socket, {
|
||||
version: "v1",
|
||||
id: "prompt-1",
|
||||
@@ -98,14 +129,58 @@ test("starts the home agent and dispatches local protocol requests to the select
|
||||
});
|
||||
|
||||
assert.equal(prompt.ok, true);
|
||||
const named = await request(socket, {
|
||||
version: "v1",
|
||||
id: "name-1",
|
||||
op: "set_session_name",
|
||||
agentId: featureAgent.id,
|
||||
payload: { name: "Feature work" },
|
||||
});
|
||||
const compacted = await request(socket, {
|
||||
version: "v1",
|
||||
id: "compact-1",
|
||||
op: "compact",
|
||||
agentId: featureAgent.id,
|
||||
payload: { customInstructions: "Preserve tests" },
|
||||
});
|
||||
assert.equal(named.ok, true);
|
||||
assert.equal(compacted.ok, true);
|
||||
assert.deepEqual(
|
||||
fixture.calls.map(({ options }) => options.cwd),
|
||||
[home, feature],
|
||||
);
|
||||
assert.deepEqual(fixture.calls[1].adapter.sent, [
|
||||
{ type: "get_state" },
|
||||
{ type: "get_state" },
|
||||
{ type: "get_session_stats" },
|
||||
{ type: "get_messages" },
|
||||
{ type: "get_commands" },
|
||||
{ type: "get_available_models" },
|
||||
{ type: "prompt", message: "Use this worktree" },
|
||||
{ type: "set_session_name", name: "Feature work" },
|
||||
{ type: "compact", customInstructions: "Preserve tests" },
|
||||
]);
|
||||
fixture.calls[1].options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
const closed = await request(socket, {
|
||||
version: "v1",
|
||||
id: "close-1",
|
||||
op: "close_session_runtime",
|
||||
payload: { runtimeId: featureAgent.runtimeId },
|
||||
});
|
||||
assert.equal(closed.ok, true);
|
||||
|
||||
const forgotten = await request(socket, {
|
||||
version: "v1",
|
||||
id: "forget-1",
|
||||
op: "forget_directory",
|
||||
payload: { worktreePath: feature },
|
||||
});
|
||||
assert.equal(forgotten.ok, true);
|
||||
assert.equal(forgotten.result.worktreePath, feature);
|
||||
assert.equal((await service.listAgents()).length, 1);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await service.close();
|
||||
|
||||
@@ -10,64 +10,24 @@ test("starts a bridge daemon around the local bridge service", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const daemon = await startBridgeDaemon({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: () => ({
|
||||
send: async () => ({ type: "response", success: true }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
}),
|
||||
});
|
||||
try {
|
||||
assert.match(daemon.socketPath, /bridge\.sock$/);
|
||||
assert.equal(daemon.listAgents()[0].worktreePath, home);
|
||||
} finally {
|
||||
await daemon.close();
|
||||
}
|
||||
const daemon = await startBridgeDaemon({ homeWorktree: home, runtimeDir: join(root, "runtime"), sessionRoot: join(root, "sessions"), startAdapter: () => ({ send: async () => ({ type: "response", success: true }), respondToExtension: () => {}, stop: async () => {} }) });
|
||||
try { assert.match(daemon.socketPath, /bridge\.sock$/); assert.equal(daemon.listAgents()[0].worktreePath, home); }
|
||||
finally { await daemon.close(); }
|
||||
});
|
||||
|
||||
test("relays only the selected agent's bridge state into a presentation update", async () => {
|
||||
let listener;
|
||||
test("relays aggregate workspace summary as compact Noctalia state", async () => {
|
||||
const updates = [];
|
||||
const relay = createNoctaliaStateRelay({
|
||||
client: {
|
||||
request: async () => ({ data: { isStreaming: false } }),
|
||||
subscribe: async (_agentId, _cursor, callback) => {
|
||||
listener = callback;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
agent: { id: "agent-1", worktreePath: "/worktrees/feature" },
|
||||
onState: (state) => updates.push(state),
|
||||
client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } },
|
||||
onState: (state) => updates.push(state), pollMs: 250,
|
||||
});
|
||||
|
||||
await relay.start();
|
||||
listener({ type: "agent_state", data: { state: "streaming" } });
|
||||
listener({
|
||||
type: "queue",
|
||||
data: { event: { steering: ["focus"], followUp: [] } },
|
||||
});
|
||||
|
||||
assert.deepEqual(updates, [
|
||||
{
|
||||
state: "idle",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 0,
|
||||
detail: "Idle",
|
||||
},
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 0,
|
||||
detail: "Streaming",
|
||||
},
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 1,
|
||||
detail: "Streaming",
|
||||
},
|
||||
]);
|
||||
await relay.start(); relay.stop();
|
||||
assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]);
|
||||
});
|
||||
|
||||
test("retains agent-scoped relay behavior only when explicitly requested", async () => {
|
||||
let listener; const updates = [];
|
||||
const relay = createNoctaliaStateRelay({ client: { request: async () => ({ data: { isStreaming: false } }), subscribe: async (_agentId, _cursor, callback) => { listener = callback; return () => {}; } }, agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, onState: (state) => updates.push(state) });
|
||||
await relay.start(); listener({ type: "agent_state", data: { state: "streaming" } });
|
||||
assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature");
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,33 +2,18 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { publishNoctaliaState } from "../src/client/noctalia-ipc.js";
|
||||
|
||||
test("publishes presentation state through Noctalia's documented IPC command", async () => {
|
||||
test("publishes aggregate presentation state through Noctalia's documented IPC command", async () => {
|
||||
const calls = [];
|
||||
await publishNoctaliaState(
|
||||
{
|
||||
state: "streaming",
|
||||
projectLabel: "feature",
|
||||
attentionCount: 2,
|
||||
detail: "Streaming",
|
||||
state: "streaming", projectLabel: "3 sessions", attentionCount: 2,
|
||||
detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors",
|
||||
openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1,
|
||||
},
|
||||
{ execute: async (command, args) => calls.push({ command, args }) },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
assert.deepEqual(calls, [{
|
||||
command: "qs",
|
||||
args: [
|
||||
"-c",
|
||||
"noctalia-shell",
|
||||
"ipc",
|
||||
"call",
|
||||
"plugin:pi-status-bridge",
|
||||
"setBridgeState",
|
||||
"streaming",
|
||||
"feature",
|
||||
"2",
|
||||
"Streaming",
|
||||
],
|
||||
},
|
||||
]);
|
||||
args: ["-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState", "streaming", "3 sessions", "2", "3 open · 1 working · 2 attention · 0 recovering · 1 errors", "3", "1", "0", "1"],
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("ships a Noctalia plugin with compact bar and presentation-only panel entry points", async () => {
|
||||
test("ships a Noctalia aggregate launcher without a primary panel", async () => {
|
||||
const manifestText = await readFile(
|
||||
"noctalia-plugin/pi-status-bridge/manifest.json",
|
||||
"utf8",
|
||||
@@ -15,9 +15,8 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
|
||||
error instanceof Error ? error.message : "manifest was not valid JSON",
|
||||
);
|
||||
}
|
||||
const [bar, panel, main] = await Promise.all([
|
||||
const [bar, main] = await Promise.all([
|
||||
readFile("noctalia-plugin/pi-status-bridge/BarWidget.qml", "utf8"),
|
||||
readFile("noctalia-plugin/pi-status-bridge/Panel.qml", "utf8"),
|
||||
readFile("noctalia-plugin/pi-status-bridge/Main.qml", "utf8"),
|
||||
]);
|
||||
|
||||
@@ -25,10 +24,18 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
|
||||
assert.deepEqual(manifest.entryPoints, {
|
||||
main: "Main.qml",
|
||||
barWidget: "BarWidget.qml",
|
||||
panel: "Panel.qml",
|
||||
});
|
||||
assert.match(bar, /pluginApi\.togglePanel\(root\.screen, visualCapsule\)/);
|
||||
assert.match(panel, /allowAttach: true/);
|
||||
assert.match(bar, /systemctl", "--user", "start", "pi-status-bridge\.service/);
|
||||
assert.match(bar, /PI_STATUS_UI_BINARY/);
|
||||
assert.match(bar, /pi-status-ui/);
|
||||
assert.match(bar, /setsid -f env TMPDIR=\/tmp/);
|
||||
assert.match(bar, /--toggle/);
|
||||
assert.doesNotMatch(bar, /togglePanel/);
|
||||
assert.doesNotMatch(bar, /Panel\.qml/);
|
||||
assert.match(main, /IpcHandler/);
|
||||
assert.match(main, /setBridgeState/);
|
||||
assert.match(main, /openCount/);
|
||||
assert.match(main, /workingCount/);
|
||||
assert.match(main, /recoveringCount/);
|
||||
assert.match(main, /errorCount/);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => {
|
||||
test("ships a v5 Noctalia aggregate launcher instead of a primary panel", async () => {
|
||||
const [manifest, bridge] = await Promise.all([
|
||||
readFile("noctalia-v5-plugin/pi-status-bridge/plugin.toml", "utf8"),
|
||||
readFile("noctalia-v5-plugin/pi-status-bridge/bridge.luau", "utf8"),
|
||||
@@ -13,9 +13,27 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as
|
||||
assert.match(bridge, /PI_STATUS_UI_BINARY/);
|
||||
assert.match(bridge, /pi-status-ui/);
|
||||
assert.match(bridge, /noctalia\.runAsync/);
|
||||
assert.match(bridge, /test -x/);
|
||||
assert.match(bridge, /setsid -f env TMPDIR=\/tmp/);
|
||||
assert.match(bridge, /--toggle/);
|
||||
assert.doesNotMatch(bridge, /--show/);
|
||||
assert.match(bridge, /pi-status-ui\.log/);
|
||||
assert.doesNotMatch(bridge, /togglePanel/);
|
||||
assert.match(bridge, /get_workspace_summary/);
|
||||
assert.match(bridge, /openCount/);
|
||||
assert.match(bridge, /workingCount/);
|
||||
assert.match(bridge, /attentionCount/);
|
||||
assert.match(bridge, /recoveringCount/);
|
||||
assert.match(bridge, /errorCount/);
|
||||
assert.match(bridge, /Pi reconnecting/);
|
||||
assert.match(bridge, /discover_home_agent\(socket\)/);
|
||||
assert.doesNotMatch(bridge, /discover_home_agent/);
|
||||
assert.doesNotMatch(bridge, /get_state/);
|
||||
assert.match(bridge, /systemctl --user start pi-status-bridge\.service/);
|
||||
assert.match(bridge, /bridge_start_in_flight/);
|
||||
assert.match(bridge, /pending_ui_open/);
|
||||
assert.match(bridge, /Bridge is starting/);
|
||||
await assert.rejects(
|
||||
access("noctalia-v5-plugin/pi-status-bridge/picker.luau"),
|
||||
{ code: "ENOENT" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -96,6 +96,32 @@ test("starts Pi in RPC mode and correlates a command response", async () => {
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("allows a per-command timeout override without serializing it to Pi", async () => {
|
||||
const fixture = createFixture();
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
commandTimeoutMs: 10,
|
||||
});
|
||||
const initial = adapter.send({ type: "get_state" }, { timeoutMs: 50 });
|
||||
const [initialCommand] = fixture.sent();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "response", id: initialCommand.id, command: "get_state", success: true })}\n`,
|
||||
);
|
||||
await initial;
|
||||
assert.deepEqual(fixture.sent()[0], {
|
||||
type: "get_state",
|
||||
id: initialCommand.id,
|
||||
});
|
||||
await assert.rejects(
|
||||
adapter.send({ type: "get_session_stats" }),
|
||||
/timed out/,
|
||||
);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("requests Pi session statistics for context and token status", async () => {
|
||||
const fixture = createFixture();
|
||||
const adapter = startPiRpcAdapter({
|
||||
@@ -115,6 +141,40 @@ test("requests Pi session statistics for context and token status", async () =>
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("forwards native session naming and compaction commands", async () => {
|
||||
const fixture = createFixture();
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
});
|
||||
|
||||
const named = adapter.send({ type: "set_session_name", name: "Release UI" });
|
||||
const [nameCommand] = fixture.sent();
|
||||
assert.equal(nameCommand.type, "set_session_name");
|
||||
assert.equal(nameCommand.name, "Release UI");
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "response", id: nameCommand.id, command: "set_session_name", success: true })}\n`,
|
||||
);
|
||||
await named;
|
||||
|
||||
const compacted = adapter.send({
|
||||
type: "compact",
|
||||
customInstructions: "Preserve implementation details",
|
||||
});
|
||||
const [, compactCommand] = fixture.sent();
|
||||
assert.equal(compactCommand.type, "compact");
|
||||
assert.equal(
|
||||
compactCommand.customInstructions,
|
||||
"Preserve implementation details",
|
||||
);
|
||||
fixture.child.stdout.write(
|
||||
`${JSON.stringify({ type: "response", id: compactCommand.id, command: "compact", success: true })}\n`,
|
||||
);
|
||||
await compacted;
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("forwards extension responses without replacing Pi's request ID", async () => {
|
||||
const fixture = createFixture();
|
||||
const events = [];
|
||||
@@ -148,6 +208,90 @@ test("forwards extension responses without replacing Pi's request ID", async ()
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("discards a fragmented oversized frame before processing the next frame", async () => {
|
||||
const fixture = createFixture();
|
||||
const errors = [];
|
||||
const events = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
maxFrameBytes: 32,
|
||||
onError: (error) => errors.push(error),
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
const oversized = JSON.stringify({
|
||||
type: "message_update",
|
||||
delta: "x".repeat(80),
|
||||
});
|
||||
fixture.child.stdout.write(oversized.slice(0, 40));
|
||||
fixture.child.stdout.write(
|
||||
`${oversized.slice(40)}\n${JSON.stringify({ type: "agent_settled" })}\n`,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
errors.map((error) => error.code),
|
||||
["frame_too_large"],
|
||||
);
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
seq: 1,
|
||||
type: "agent_state",
|
||||
data: { state: "idle", event: { type: "agent_settled" } },
|
||||
},
|
||||
]);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("discards an oversized complete frame before processing the next frame", async () => {
|
||||
const fixture = createFixture();
|
||||
const errors = [];
|
||||
const events = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
maxFrameBytes: 32,
|
||||
onError: (error) => errors.push(error),
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
const oversized = JSON.stringify({
|
||||
type: "message_update",
|
||||
delta: "x".repeat(80),
|
||||
});
|
||||
fixture.child.stdout.write(
|
||||
`${oversized}\n${JSON.stringify({ type: "agent_settled" })}\n`,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
errors.map((error) => error.code),
|
||||
["frame_too_large"],
|
||||
);
|
||||
assert.equal(events.length, 1);
|
||||
assert.equal(events[0].data.state, "idle");
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("does not report an unterminated frame after discarding an oversized frame", async () => {
|
||||
const fixture = createFixture();
|
||||
const errors = [];
|
||||
const adapter = startPiRpcAdapter({
|
||||
cwd: "/workspace/home",
|
||||
sessionDir: "/workspace/sessions",
|
||||
spawnProcess: fixture.spawn,
|
||||
maxFrameBytes: 32,
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
fixture.child.stdout.write("x".repeat(40));
|
||||
fixture.child.stdout.end();
|
||||
|
||||
assert.deepEqual(
|
||||
errors.map((error) => error.code),
|
||||
["frame_too_large"],
|
||||
);
|
||||
await adapter.stop();
|
||||
});
|
||||
|
||||
test("reports invalid child output and rejects pending commands on child exit", async () => {
|
||||
const fixture = createFixture();
|
||||
const errors = [];
|
||||
|
||||
@@ -47,6 +47,20 @@ test("accepts bridge-managed directory and session operations", () => {
|
||||
id: "request-1",
|
||||
op: "list_directories",
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "forget_directory",
|
||||
payload: { worktreePath: "/projects/feature" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "forget_directory",
|
||||
payload: { worktreePath: "/projects/feature" },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "list_sessions", agentId: "agent-main" })),
|
||||
{
|
||||
@@ -65,6 +79,15 @@ test("accepts bridge-managed directory and session operations", () => {
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "renew_session", agentId: "agent-main" })),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "renew_session",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
@@ -94,6 +117,100 @@ test("accepts bridge-managed directory and session operations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts additive multi-session runtime operations and numeric workspace cursors", () => {
|
||||
const worktreePath = "/home/alex/project";
|
||||
const sessionPath = "/home/alex/project/sessions/one.jsonl";
|
||||
for (const value of [
|
||||
{ op: "get_workspace" },
|
||||
{ op: "get_workspace_summary" },
|
||||
{ op: "create_session_runtime", payload: { worktreePath } },
|
||||
{ op: "create_quick_runtime", payload: { worktreePath } },
|
||||
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
|
||||
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
|
||||
{ op: "close_quick_runtime", payload: { runtimeId: "runtime-1" } },
|
||||
{ op: "list_directory_sessions", payload: { worktreePath } },
|
||||
{
|
||||
op: "get_session_runtime_snapshot",
|
||||
payload: { runtimeId: "runtime-1" },
|
||||
},
|
||||
{ op: "subscribe_workspace", payload: { cursor: 42 } },
|
||||
])
|
||||
assert.equal(parseRequestFrame(request(value)).op, value.op);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "subscribe_workspace",
|
||||
payload: { cursor: "opaque" },
|
||||
}),
|
||||
),
|
||||
/cursor must be a non-negative integer/,
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts native session naming and manual compaction requests", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: "Release UI" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: "Release UI" },
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(request({ op: "compact", agentId: "agent-main" })),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
request({
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
payload: { customInstructions: "Preserve implementation details" },
|
||||
}),
|
||||
),
|
||||
{
|
||||
version: "v1",
|
||||
id: "request-1",
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
payload: { customInstructions: "Preserve implementation details" },
|
||||
},
|
||||
);
|
||||
for (const invalidRequest of [
|
||||
request({
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: " " },
|
||||
}),
|
||||
request({
|
||||
op: "set_session_name",
|
||||
agentId: "agent-main",
|
||||
payload: { name: "Valid", extra: true },
|
||||
}),
|
||||
request({
|
||||
op: "compact",
|
||||
agentId: "agent-main",
|
||||
payload: { customInstructions: " " },
|
||||
}),
|
||||
request({ op: "compact", agentId: "agent-main", payload: { extra: true } }),
|
||||
])
|
||||
assert.throws(() => parseRequestFrame(invalidRequest), ProtocolError);
|
||||
});
|
||||
|
||||
test("accepts an agent session-statistics request", () => {
|
||||
assert.deepEqual(
|
||||
parseRequestFrame(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("Quick Pane polls snapshots, renders answers, and gates handoff", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /get_session_runtime_snapshot/);
|
||||
assert.match(source, /setInterval\(\(\) => void refresh\(\), 800\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/<pre style=\{\{ whiteSpace: "pre-wrap" \}\}>\{answer\}<\/pre>/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/\{\(escalation \|\| error\) && \(\s*<button onClick=\{continueFull\}>Continue in full session<\/button>\s*\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("main Settings owns Quick Pane configuration while pane keeps request controls", async () => {
|
||||
const [pane, app, persistence] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(new URL("../ui/src/App.tsx", import.meta.url), "utf8"),
|
||||
readFile(
|
||||
new URL("../ui/src/workspace/persistence.ts", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
assert.match(app, /load_ui_state/);
|
||||
assert.match(app, /save_ui_state/);
|
||||
assert.match(app, /Save Quick Pane settings/);
|
||||
assert.match(pane, /get_model_catalog/);
|
||||
assert.match(pane, /set_thinking_level/);
|
||||
assert.doesNotMatch(pane, /showSettings/);
|
||||
assert.doesNotMatch(pane, /Save Quick Pane settings/);
|
||||
assert.doesNotMatch(pane, /setRequest\(""\)/);
|
||||
assert.match(persistence, /quickPane: current\.quickPane/);
|
||||
});
|
||||
|
||||
test("Quick Pane routes extension responses through existing bridge command", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../ui/src/quick-pane/QuickPane.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/<ExtensionDialog extension=\{extension\} onRespond=\{respond\} \/>/,
|
||||
);
|
||||
assert.match(source, /invoke\("respond_to_extension"/);
|
||||
assert.match(source, /requestId: extension\.id/);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("documents and exposes simple user-service lifecycle commands", async () => {
|
||||
const [readme, uiReadme, packageJson, uiPackageJson] = await Promise.all([
|
||||
readFile("README.md", "utf8"),
|
||||
readFile("ui/README.md", "utf8"),
|
||||
readFile("package.json", "utf8"),
|
||||
readFile("ui/package.json", "utf8"),
|
||||
]);
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(packageJson);
|
||||
} catch (error) {
|
||||
assert.fail(`package.json must contain valid JSON: ${String(error)}`);
|
||||
}
|
||||
const scripts = manifest.scripts;
|
||||
for (const command of [
|
||||
"install",
|
||||
"start",
|
||||
"restart",
|
||||
"stop",
|
||||
"status",
|
||||
"logs",
|
||||
])
|
||||
assert.equal(
|
||||
typeof scripts[`bridge:service:${command}`],
|
||||
"string",
|
||||
`missing bridge:service:${command}`,
|
||||
);
|
||||
let uiManifest;
|
||||
try {
|
||||
uiManifest = JSON.parse(uiPackageJson);
|
||||
} catch (error) {
|
||||
assert.fail(`ui/package.json must contain valid JSON: ${String(error)}`);
|
||||
}
|
||||
assert.match(readme, /npm run bridge:service:restart/);
|
||||
assert.match(readme, /pi-status-ui --toggle/);
|
||||
assert.match(readme, /Restart Pi.*selected directory/);
|
||||
assert.match(uiReadme, /bridge:service:install/);
|
||||
assert.match(uiReadme, /tauri:release/);
|
||||
assert.equal(uiManifest.scripts["tauri:release"], "tauri build --no-bundle");
|
||||
});
|
||||
@@ -42,6 +42,56 @@ function collectFrames(socket, count) {
|
||||
});
|
||||
}
|
||||
|
||||
test("workspace subscription exposes daemon identity, replay boundaries, and globally ordered live events", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-workspace-subscribe-"));
|
||||
const home = join(root, "home");
|
||||
await mkdir(home);
|
||||
const calls = [];
|
||||
const service = await startBridgeService({
|
||||
homeWorktree: home,
|
||||
runtimeDir: join(root, "runtime"),
|
||||
sessionRoot: join(root, "sessions"),
|
||||
startAdapter: (options) => {
|
||||
const adapter = {
|
||||
send: async () => ({ type: "response", success: true, data: {} }),
|
||||
respondToExtension: () => {},
|
||||
stop: async () => {},
|
||||
};
|
||||
calls.push({ options, adapter });
|
||||
return adapter;
|
||||
},
|
||||
});
|
||||
const socket = await connect(service.socketPath);
|
||||
|
||||
try {
|
||||
const frames = collectFrames(socket, 2);
|
||||
socket.write(
|
||||
`${JSON.stringify({ version: "v1", id: "workspace-1", op: "subscribe_workspace", payload: { cursor: 0 } })}\n`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
calls[0].options.onEvent({
|
||||
type: "stream",
|
||||
data: { event: { type: "message_update", delta: "live" } },
|
||||
});
|
||||
}, 10);
|
||||
const [replay, live] = await frames;
|
||||
assert.equal(replay.ok, true);
|
||||
assert.equal(typeof replay.result.bridgeInstanceId, "string");
|
||||
assert.equal(replay.result.truncated, false);
|
||||
assert.equal(Number.isSafeInteger(replay.result.firstAvailableSeq), true);
|
||||
assert.equal(Number.isSafeInteger(replay.result.latestSeq), true);
|
||||
assert.ok(replay.result.events.length >= 1);
|
||||
assert.equal(live.type, "event");
|
||||
assert.equal(live.event.bridgeInstanceId, replay.result.bridgeInstanceId);
|
||||
assert.equal(live.event.type, "runtime_event");
|
||||
assert.equal(live.event.data.eventType, "stream");
|
||||
assert.ok(live.event.seq > replay.result.events.at(-1).seq);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
await service.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("replays then streams subscribed agent events on the same local socket", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-subscribe-"));
|
||||
const home = join(root, "home");
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("rebuilds the Tauri app when frontend assets change", async () => {
|
||||
const buildScript = await readFile("ui/src-tauri/build.rs", "utf8");
|
||||
assert.match(buildScript, /cargo:rerun-if-changed=\.\.\/dist/);
|
||||
});
|
||||
+78
-202
@@ -2,213 +2,89 @@ import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => {
|
||||
const source = await readFile("ui/src/App.tsx", "utf8");
|
||||
for (const method of [
|
||||
"select",
|
||||
"confirm",
|
||||
"input",
|
||||
"editor",
|
||||
"notify",
|
||||
"setStatus",
|
||||
"setWidget",
|
||||
"setTitle",
|
||||
"set_editor_text",
|
||||
]) {
|
||||
assert.match(source, new RegExp(`case "${method}"`));
|
||||
}
|
||||
assert.match(source, /setPendingExtension\(extension\)/);
|
||||
assert.doesNotMatch(source, /This extension UI is unsupported/);
|
||||
assert.match(source, /function stripAnsi/);
|
||||
assert.match(source, /stripAnsi\(extension\.statusText\)/);
|
||||
assert.match(source, /className="pi-controls"/);
|
||||
assert.match(source, /aria-label="Model and thinking controls"/);
|
||||
assert.doesNotMatch(source, /<details className="pi-controls">/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/,
|
||||
);
|
||||
assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/);
|
||||
const sources = Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/workspace/reducer.ts", "utf8"),
|
||||
readFile("ui/src/workspace/useWorkspace.ts", "utf8"),
|
||||
readFile("ui/src/workspace/persistence.ts", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
|
||||
test("wires the desktop to stable runtime workspace APIs", async () => {
|
||||
const [app, reducer, hook] = await sources;
|
||||
for (const operation of [
|
||||
"create_session_runtime",
|
||||
"open_session_runtime",
|
||||
"close_session_runtime",
|
||||
"list_directory_sessions",
|
||||
"get_session_runtime_snapshot",
|
||||
"renew_session",
|
||||
"subscribe_workspace",
|
||||
])
|
||||
assert.match(`${app}\n${hook}`, new RegExp(operation));
|
||||
assert.doesNotMatch(app, /switch_session/);
|
||||
assert.doesNotMatch(app, /["']new_session["']/);
|
||||
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
|
||||
assert.match(reducer, /requestGeneration/);
|
||||
assert.match(reducer, /loadedThroughSeq/);
|
||||
});
|
||||
|
||||
test("keeps presentation persistence allowlisted and agent IDs ephemeral", async () => {
|
||||
const [, , , persistence] = await sources;
|
||||
assert.match(persistence, /load_ui_state/);
|
||||
assert.match(persistence, /save_ui_state/);
|
||||
assert.match(persistence, /directoryOrder/);
|
||||
assert.match(persistence, /drafts/);
|
||||
assert.doesNotMatch(persistence, /agentId/);
|
||||
assert.doesNotMatch(persistence, /messages/);
|
||||
assert.doesNotMatch(persistence, /extensions/);
|
||||
});
|
||||
|
||||
test("renders directory sidebar, accessible session tabs, warnings, and reduced motion", async () => {
|
||||
const [app, , , , css] = await sources;
|
||||
const [sidebar, tabs] = await Promise.all([
|
||||
readFile("ui/src/components/DirectorySidebar.tsx", "utf8"),
|
||||
readFile("ui/src/components/SessionTabs.tsx", "utf8"),
|
||||
]);
|
||||
assert.match(sidebar, /aria-label="Directories"/);
|
||||
assert.match(tabs, /role="tablist"/);
|
||||
assert.match(tabs, /role="tab"/);
|
||||
assert.match(tabs, /aria-selected/);
|
||||
assert.match(tabs, /ArrowRight/);
|
||||
assert.match(app, /Multiple sessions are open in the same directory/);
|
||||
assert.match(app, /Many Pi sessions are open/);
|
||||
assert.match(css, /prefers-reduced-motion/);
|
||||
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/window\.addEventListener\("keydown", handleKeydown, true\)/,
|
||||
);
|
||||
assert.match(source, /useLayoutEffect/);
|
||||
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
|
||||
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
|
||||
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/const lastEventSequence = useRef<Record<string, number>>/,
|
||||
);
|
||||
assert.match(source, /generation !== loadGeneration\.current/);
|
||||
assert.match(source, /current\.phase !== "working"/);
|
||||
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
|
||||
assert.match(source, /\.toFixed\(1\)/);
|
||||
assert.match(source, /formatTokens\(stats\.tokens\.input\)/);
|
||||
assert.match(source, /formatTokens\(stats\.tokens\.output\)/);
|
||||
assert.match(source, /formatTokens\(cacheTokens\)/);
|
||||
assert.match(source, /function updateWorkProgress/);
|
||||
assert.match(
|
||||
source,
|
||||
/className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
assert.match(source, /event\.type === "tool"/);
|
||||
assert.match(source, /event\.type === "queue"/);
|
||||
assert.match(source, /composer-progress/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/<section className=\{`work-progress \$\{workProgress\.phase\}`\}/,
|
||||
css,
|
||||
/\.session-row-content\s*>\s*strong\s*\{[^}]*color:\s*#f1f3f4/s,
|
||||
);
|
||||
});
|
||||
|
||||
test("makes the window draggable from the non-interactive header", async () => {
|
||||
const [component, styles, capability] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
test("preserves native session naming, compaction, composer, and extension responses", async () => {
|
||||
const [app] = await sources;
|
||||
const [conversation, extension] = await Promise.all([
|
||||
readFile("ui/src/components/ConversationWorkspace.tsx", "utf8"),
|
||||
readFile("ui/src/components/ExtensionDialog.tsx", "utf8"),
|
||||
]);
|
||||
assert.match(app, /set_session_name/);
|
||||
assert.match(app, /compact/);
|
||||
assert.match(app, /submit_prompt/);
|
||||
assert.match(app, /respond_to_extension/);
|
||||
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.match(conversation, /className="todos-pane"/);
|
||||
assert.match(extension, /role="dialog"/);
|
||||
assert.match(extension, /aria-modal="true"/);
|
||||
});
|
||||
|
||||
test("configures native window behavior and folder dialog capabilities", async () => {
|
||||
const [config, capability, cargo] = await Promise.all([
|
||||
readFile("ui/src-tauri/tauri.conf.json", "utf8"),
|
||||
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
|
||||
readFile("ui/src-tauri/Cargo.toml", "utf8"),
|
||||
]);
|
||||
assert.match(component, /startDragging\(\)/);
|
||||
assert.match(
|
||||
component,
|
||||
/closest\(\s*"button, input, select, textarea",?\s*\)/,
|
||||
);
|
||||
assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/);
|
||||
assert.match(config, /"width"\s*:\s*1100/);
|
||||
assert.match(capability, /core:window:allow-start-dragging/);
|
||||
});
|
||||
|
||||
test("opens local follow-up choices for /model and /resume", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /const controlCommands: Command\[\]/);
|
||||
assert.match(component, /followUp: "model"/);
|
||||
assert.match(component, /followUp: "resume"/);
|
||||
assert.match(component, /followUp: "thinking"/);
|
||||
assert.match(component, /const nativeCommand = controlCommands\.find/);
|
||||
assert.match(component, /function chooseThinking/);
|
||||
assert.match(component, /function openCommandFollowUp/);
|
||||
assert.match(component, /function chooseModel/);
|
||||
assert.match(component, /className="command-followup"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(styles, /\.command-followup \{/);
|
||||
assert.match(styles, /\.command-choice-list \{/);
|
||||
});
|
||||
|
||||
test("shows bridge-managed directory status and sessions for the opened directory", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(
|
||||
component,
|
||||
/invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/,
|
||||
);
|
||||
assert.match(
|
||||
component,
|
||||
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
|
||||
);
|
||||
assert.match(component, /function chooseDirectory/);
|
||||
assert.match(component, /function startNewSession/);
|
||||
assert.match(component, /function switchSession/);
|
||||
assert.match(component, /className="session-panel"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(component, /state\.isStreaming/);
|
||||
assert.match(styles, /\.session-panel \{/);
|
||||
assert.match(styles, /\.session-list \{/);
|
||||
});
|
||||
|
||||
test("renders todo-plugin state and compact context/token status", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /function currentTodos/);
|
||||
assert.match(component, /message\.toolName === "todo"/);
|
||||
assert.match(component, /className="todos-pane"/);
|
||||
assert.match(component, /className="workflow-main"/);
|
||||
assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/);
|
||||
assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/);
|
||||
assert.match(styles, /\.workflow-main \{/);
|
||||
assert.match(styles, /\.todos-pane \{/);
|
||||
assert.match(styles, /\.status-metric \{/);
|
||||
});
|
||||
|
||||
test("shows an optimistic prompt until the transcript receives it", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /type PendingSubmission/);
|
||||
assert.match(
|
||||
component,
|
||||
/const \[pendingSubmissions, setPendingSubmissions\]/,
|
||||
);
|
||||
assert.match(component, /setStatus\("Sending prompt to Pi…"\)/);
|
||||
assert.match(component, /phase: "sending"/);
|
||||
assert.match(component, /phase: "sent"/);
|
||||
assert.match(component, /Sent · waiting for Pi/);
|
||||
assert.match(component, /receivedMessages\.some/);
|
||||
assert.match(component, /pending-message/);
|
||||
assert.match(styles, /\.pending-message \{/);
|
||||
assert.match(styles, /\.pending-message\.sending/);
|
||||
});
|
||||
|
||||
test("distinguishes user and assistant messages", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.message\.user \{/);
|
||||
assert.match(styles, /background: #192536/);
|
||||
assert.match(styles, /\.message\.assistant \{/);
|
||||
assert.match(styles, /background: #1a241a/);
|
||||
});
|
||||
|
||||
test("uses a subtle focus treatment for the prompt editor", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.composer textarea:focus \{/);
|
||||
assert.match(styles, /outline: none/);
|
||||
assert.match(styles, /border-color: #6f8b56/);
|
||||
});
|
||||
|
||||
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
|
||||
const source = await readFile("ui/src/App.tsx", "utf8");
|
||||
assert.match(source, /onKeyDown=\{\(event\) => \{/);
|
||||
assert.match(source, /event\.key !== "Enter"/);
|
||||
assert.match(source, /event\.shiftKey/);
|
||||
assert.match(source, /event\.nativeEvent\.isComposing/);
|
||||
assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/);
|
||||
assert.match(source, /Enter to send · Shift\+Enter for newline/);
|
||||
});
|
||||
|
||||
test("uses a compact workflow layout that keeps the composer and transcript stable", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /className="workflow-header"/);
|
||||
assert.match(component, /className="workflow"/);
|
||||
assert.match(component, /className="workflow-footer"/);
|
||||
assert.match(component, /className="composer-stack"/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/,
|
||||
);
|
||||
assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/);
|
||||
assert.match(
|
||||
component,
|
||||
/className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/,
|
||||
);
|
||||
assert.match(styles, /\.transcript\.working \{/);
|
||||
assert.match(styles, /conic-gradient\(/);
|
||||
assert.match(styles, /transcript-border-orbit/);
|
||||
assert.match(styles, /prefers-reduced-motion: reduce/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/,
|
||||
);
|
||||
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
|
||||
assert.match(cargo, /tauri-plugin-single-instance/);
|
||||
assert.match(cargo, /tauri-plugin-dialog/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { createWorkspaceStore } from "../src/bridge/workspace-store.js";
|
||||
|
||||
test("persists an owner-only versioned workspace manifest atomically", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-store-"));
|
||||
const store = createWorkspaceStore(join(root, "sessions"));
|
||||
const first = await store.load();
|
||||
assert.equal(first.migrating, true);
|
||||
|
||||
await store.save({
|
||||
version: 2,
|
||||
migrated: true,
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: "runtime-1",
|
||||
worktreePath: join(root, "home"),
|
||||
sessionPath: join(root, "sessions", "agent", "one.jsonl"),
|
||||
sessionId: "session-1",
|
||||
},
|
||||
],
|
||||
});
|
||||
const metadata = await stat(store.filePath);
|
||||
assert.equal(metadata.mode & 0o777, 0o600);
|
||||
const restored = (await store.load()).manifest;
|
||||
assert.equal(restored.runtimes[0].runtimeId, "runtime-1");
|
||||
assert.deepEqual(restored.directories, [join(root, "home")]);
|
||||
assert.equal((await readFile(store.filePath, "utf8")).endsWith("\n"), true);
|
||||
});
|
||||
|
||||
test("accepts old manifests and persists managed empty directories", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-directories-"));
|
||||
const store = createWorkspaceStore(join(root, "sessions"));
|
||||
await store.save({
|
||||
version: 2,
|
||||
migrated: true,
|
||||
directories: [join(root, "empty")],
|
||||
runtimes: [],
|
||||
});
|
||||
assert.deepEqual((await store.load()).manifest.directories, [
|
||||
join(root, "empty"),
|
||||
]);
|
||||
await writeFile(
|
||||
store.filePath,
|
||||
JSON.stringify({ version: 2, migrated: true, runtimes: [] }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
assert.deepEqual((await store.load()).manifest.directories, []);
|
||||
});
|
||||
|
||||
test("reports a corrupt manifest without treating it as first migration", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-corrupt-"));
|
||||
const store = createWorkspaceStore(join(root, "sessions"));
|
||||
await store.save({ version: 2, migrated: true, runtimes: [] });
|
||||
await writeFile(store.filePath, "{broken", { mode: 0o600 });
|
||||
|
||||
const loaded = await store.load();
|
||||
assert.equal(loaded.migrating, false);
|
||||
assert.equal(loaded.manifest.runtimes.length, 0);
|
||||
assert.equal(loaded.issue.code, "invalid_workspace_manifest");
|
||||
});
|
||||
|
||||
test("recovers its serialized write queue after a failed save", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-workspace-retry-"));
|
||||
const sessionRoot = join(root, "sessions");
|
||||
const store = createWorkspaceStore(sessionRoot);
|
||||
await mkdir(sessionRoot);
|
||||
await mkdir(store.filePath);
|
||||
await assert.rejects(
|
||||
store.save({ version: 2, migrated: true, runtimes: [] }),
|
||||
);
|
||||
await rm(store.filePath, { recursive: true });
|
||||
await store.save({ version: 2, migrated: true, runtimes: [] });
|
||||
assert.deepEqual((await store.load()).manifest.runtimes, []);
|
||||
});
|
||||
+50
-4
@@ -1,7 +1,53 @@
|
||||
# Tauri + React + Typescript
|
||||
# Pi Status UI
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
## Reason for existence
|
||||
|
||||
## Recommended IDE Setup
|
||||
Provide the desktop control surface for Pi Status Bridge. The UI presents bridge state and forwards user intent; it never owns Pi sessions, worktree routing, or approval policy.
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
## Requirements
|
||||
|
||||
- Node.js 20+.
|
||||
- Rust stable and the native dependencies required by [Tauri v2](https://v2.tauri.app/start/prerequisites/).
|
||||
- A running Pi Status Bridge socket. By default the app uses `$XDG_RUNTIME_DIR/pi-status-bridge/bridge.sock`; set `PI_STATUS_BRIDGE_SOCKET` to override it.
|
||||
|
||||
## Develop
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
npm --prefix ui ci
|
||||
npm --prefix ui run tauri dev
|
||||
```
|
||||
|
||||
The UI needs a running bridge. For day-to-day use, keep it in the background with the root project's `npm run bridge:service:install` command. For foreground development, run the bridge in a separate terminal:
|
||||
|
||||
```bash
|
||||
node src/bridge/cli.js --worktree "$HOME"
|
||||
```
|
||||
|
||||
The Tauri app connects to the bridge on startup. See the [root README](../README.md#keep-the-bridge-running-in-the-background-recommended) for start, restart, status, and log commands.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm --prefix ui run dev # Vite frontend only
|
||||
npm --prefix ui run build # typecheck and build frontend assets
|
||||
npm --prefix ui run tauri dev # run the desktop client in development
|
||||
npm --prefix ui run tauri:release # build the production executable used by Noctalia/Mod+Space
|
||||
npm --prefix ui run tauri build # package installers (requires platform bundle tooling)
|
||||
```
|
||||
|
||||
## Boundaries
|
||||
|
||||
- The UI sends operations only to the selected bridge agent.
|
||||
- Extension responses are forwarded unchanged to Pi.
|
||||
- The UI must not add a network transport or persist competing model/thinking defaults.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cargo test --manifest-path ui/src-tauri/Cargo.toml
|
||||
npm --prefix ui run build
|
||||
```
|
||||
|
||||
See the [root README](../README.md) for bridge setup and repository-wide verification.
|
||||
|
||||
Generated
+1230
-3
File diff suppressed because it is too large
Load Diff
+20
-5
@@ -2,25 +2,40 @@
|
||||
"name": "pi-status-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Standalone desktop client for Pi Status Bridge",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@git.commumedia.org:2222/alex/pi-gui.git"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"tauri:release": "tauri build --no-bundle"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.4",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"@tauri-apps/cli": "^2"
|
||||
"vitest": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+84
@@ -2168,6 +2168,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -2383,6 +2384,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
]
|
||||
@@ -2685,6 +2687,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3363,6 +3389,64 @@ dependencies = [
|
||||
"tauri-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin"
|
||||
version = "2.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"glob",
|
||||
"plist",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.19",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.19",
|
||||
"toml 1.1.3+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.3"
|
||||
|
||||
@@ -3,6 +3,8 @@ name = "pi-status-ui"
|
||||
version = "0.1.0"
|
||||
description = "Standalone desktop client for Pi Status Bridge"
|
||||
authors = ["alex"]
|
||||
license = "Apache-2.0"
|
||||
repository = "ssh://git@git.commumedia.org:2222/alex/pi-gui.git"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@@ -21,7 +23,8 @@ tauri-build = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
|
||||
tokio = { version = "1", features = ["io-util", "net", "rt", "macros", "time"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=../dist");
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"description": "Capability for Pi Status windows",
|
||||
"windows": [
|
||||
"main",
|
||||
"quick-pane"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
+991
-12
File diff suppressed because it is too large
Load Diff
+358
-8
@@ -1,15 +1,192 @@
|
||||
mod bridge;
|
||||
mod ui_state;
|
||||
|
||||
use serde_json::Value;
|
||||
use std::sync::Mutex;
|
||||
use serde_json::{json, Value};
|
||||
use std::{env, path::Path, sync::{Arc, Mutex}, time::Duration};
|
||||
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
||||
|
||||
struct Subscription(Mutex<Option<JoinHandle<()>>>);
|
||||
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
|
||||
struct WorkspaceSubscription {
|
||||
generation: Arc<bridge::SubscriptionGeneration>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
fn replace_subscription<T>(
|
||||
task_slot: &Mutex<Option<T>>,
|
||||
generation: &bridge::SubscriptionGeneration,
|
||||
create: impl FnOnce(u64) -> T,
|
||||
) -> Result<Option<T>, String> {
|
||||
let mut slot = task_slot
|
||||
.lock()
|
||||
.map_err(|_| "Could not update workspace subscription".to_owned())?;
|
||||
let expected_generation = generation.replace()?;
|
||||
let previous = slot.take();
|
||||
*slot = Some(create(expected_generation));
|
||||
Ok(previous)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum WindowAction {
|
||||
Hide,
|
||||
ShowAndFocus,
|
||||
}
|
||||
|
||||
fn window_action(args: &[String], is_visible: bool) -> WindowAction {
|
||||
if args.iter().any(|arg| arg == "--hide")
|
||||
|| (args.iter().any(|arg| arg == "--toggle") && is_visible)
|
||||
{
|
||||
WindowAction::Hide
|
||||
} else {
|
||||
WindowAction::ShowAndFocus
|
||||
}
|
||||
}
|
||||
|
||||
fn socket_path() -> Result<String, String> {
|
||||
bridge::default_socket_path()
|
||||
}
|
||||
|
||||
fn requested_quick_pane(args: &[String]) -> bool {
|
||||
args.iter().any(|arg| arg == "--quick")
|
||||
}
|
||||
|
||||
fn show_quick_pane(app: &AppHandle) {
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.hide();
|
||||
}
|
||||
if let Some(window) = app.get_webview_window("quick-pane") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn requested_new_worktree_argument(args: &[String]) -> Option<String> {
|
||||
args.iter()
|
||||
.position(|arg| arg == "--worktree")
|
||||
.and_then(|index| args.get(index + 1))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn requested_new_worktree(app: &AppHandle, args: &[String]) -> Option<String> {
|
||||
if !args.iter().any(|arg| arg == "--new") {
|
||||
return None;
|
||||
}
|
||||
let saved_default = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.ok()
|
||||
.and_then(|directory| ui_state::load_from(&directory).ok())
|
||||
.and_then(|state| state.default_new_session_worktree);
|
||||
let worktree = requested_new_worktree_argument(args)
|
||||
.or(saved_default)
|
||||
.or_else(|| env::var("PI_STATUS_DEFAULT_WORKTREE").ok())?;
|
||||
Path::new(&worktree).is_absolute().then_some(worktree)
|
||||
}
|
||||
|
||||
fn launch_new_session(app: AppHandle, args: &[String]) {
|
||||
let Some(worktree_path) = requested_new_worktree(&app, args) else { return };
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Give a newly-created webview time to register its frontend listeners.
|
||||
tokio::time::sleep(Duration::from_millis(350)).await;
|
||||
let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "starting",
|
||||
"detail": "Starting a new Pi session…"
|
||||
}));
|
||||
match socket_path().map(|socket| (socket, worktree_path)) {
|
||||
Ok((socket, worktree_path)) => match bridge::create_session_runtime(&socket, &worktree_path).await {
|
||||
Ok(result) => { let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "ready",
|
||||
"runtimeId": result.runtime.runtime_id,
|
||||
"detail": "New session ready"
|
||||
})); }
|
||||
Err(error) => { let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "error",
|
||||
"detail": format!("Could not start session: {error}")
|
||||
})); }
|
||||
},
|
||||
Err(error) => { let _ = app.emit("workspace-new-session", json!({
|
||||
"phase": "error",
|
||||
"detail": error
|
||||
})); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_workspace() -> Result<bridge::Workspace, String> {
|
||||
bridge::get_workspace(&socket_path()?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_workspace_summary() -> Result<bridge::WorkspaceSummary, String> {
|
||||
bridge::get_workspace_summary(&socket_path()?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_model_catalog() -> Result<Value, String> {
|
||||
bridge::get_model_catalog(&socket_path()?).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
||||
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_quick_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
|
||||
bridge::create_quick_runtime(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_session_runtime(
|
||||
worktree_path: String,
|
||||
session_path: String,
|
||||
) -> Result<bridge::RuntimeResult, String> {
|
||||
bridge::open_session_runtime(&socket_path()?, &worktree_path, &session_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn close_session_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
|
||||
bridge::close_session_runtime(&socket_path()?, &runtime_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn close_quick_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
|
||||
bridge::close_quick_runtime(&socket_path()?, &runtime_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_directory_sessions(
|
||||
worktree_path: String,
|
||||
) -> Result<bridge::DirectorySessionsResult, String> {
|
||||
bridge::list_directory_sessions(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_session_runtime_snapshot(
|
||||
runtime_id: String,
|
||||
) -> Result<bridge::RuntimeSnapshot, String> {
|
||||
bridge::get_session_runtime_snapshot(&socket_path()?, &runtime_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn load_ui_state(app: AppHandle) -> Result<ui_state::UiStateV1, String> {
|
||||
let directory = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| error.to_string())?;
|
||||
ui_state::load_from(&directory)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_ui_state(app: AppHandle, state: ui_state::UiStateV1) -> Result<(), String> {
|
||||
let directory = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| error.to_string())?;
|
||||
ui_state::save_to(&directory, &state)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_agents() -> Result<Value, String> {
|
||||
bridge::list_agents(&socket_path()?).await
|
||||
@@ -25,6 +202,11 @@ async fn select_worktree(worktree_path: String) -> Result<Value, String> {
|
||||
bridge::select_worktree(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn forget_directory(worktree_path: String) -> Result<Value, String> {
|
||||
bridge::forget_directory(&socket_path()?, &worktree_path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_agent(agent_id: String) -> Result<Value, String> {
|
||||
bridge::load_agent(&socket_path()?, &agent_id).await
|
||||
@@ -46,7 +228,7 @@ async fn new_session(agent_id: String) -> Result<Value, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn submit_prompt(agent_id: String, message: String) -> Result<(), String> {
|
||||
async fn submit_prompt(agent_id: String, message: String) -> Result<Value, String> {
|
||||
bridge::submit_prompt(&socket_path()?, &agent_id, &message).await
|
||||
}
|
||||
|
||||
@@ -65,6 +247,11 @@ async fn restart(agent_id: String) -> Result<(), String> {
|
||||
bridge::command(&socket_path()?, &agent_id, "restart").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn renew_session(agent_id: String) -> Result<(), String> {
|
||||
bridge::command(&socket_path()?, &agent_id, "renew_session").await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_model(agent_id: String, provider: String, model_id: String) -> Result<(), String> {
|
||||
bridge::set_model(&socket_path()?, &agent_id, &provider, &model_id).await
|
||||
@@ -75,6 +262,26 @@ async fn set_thinking_level(agent_id: String, level: String) -> Result<(), Strin
|
||||
bridge::set_thinking_level(&socket_path()?, &agent_id, &level).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_session_name(agent_id: String, name: String) -> Result<(), String> {
|
||||
bridge::set_session_name(&socket_path()?, &agent_id, &name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pi_rpc_command(agent_id: String, command: String, input: Value) -> Result<Value, String> {
|
||||
bridge::request(
|
||||
&socket_path()?,
|
||||
"pi_rpc_command",
|
||||
Some(&agent_id),
|
||||
Some(serde_json::json!({ "command": command, "input": input })),
|
||||
).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn compact(agent_id: String, custom_instructions: Option<String>) -> Result<(), String> {
|
||||
bridge::compact(&socket_path()?, &agent_id, custom_instructions.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn respond_to_extension(
|
||||
agent_id: String,
|
||||
@@ -87,7 +294,7 @@ async fn respond_to_extension(
|
||||
#[tauri::command]
|
||||
fn subscribe_agent(
|
||||
app: AppHandle,
|
||||
subscriptions: State<'_, Subscription>,
|
||||
subscriptions: State<'_, LegacySubscription>,
|
||||
agent_id: String,
|
||||
cursor: u64,
|
||||
) -> Result<(), String> {
|
||||
@@ -102,7 +309,9 @@ fn subscribe_agent(
|
||||
}
|
||||
let event_app = app.clone();
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) = bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await {
|
||||
if let Err(error) =
|
||||
bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await
|
||||
{
|
||||
let _ = event_app.emit(
|
||||
"bridge-error",
|
||||
serde_json::json!({ "agentId": agent_id, "message": error }),
|
||||
@@ -117,20 +326,111 @@ fn subscribe_agent(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn subscribe_workspace(
|
||||
app: AppHandle,
|
||||
subscription: State<'_, WorkspaceSubscription>,
|
||||
cursor: Option<ui_state::WorkspaceCursor>,
|
||||
) -> Result<(), String> {
|
||||
let socket = socket_path()?;
|
||||
let event_app = app.clone();
|
||||
let generation = Arc::clone(&subscription.generation);
|
||||
let previous = replace_subscription(
|
||||
&subscription.task,
|
||||
&subscription.generation,
|
||||
move |expected_generation| {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let initial = bridge::SubscriptionCursor {
|
||||
bridge_instance_id: cursor
|
||||
.as_ref()
|
||||
.map(|value| value.bridge_instance_id.clone()),
|
||||
seq: cursor.map(|value| value.seq).unwrap_or(0),
|
||||
};
|
||||
let _ = bridge::subscribe_workspace(
|
||||
socket,
|
||||
initial,
|
||||
event_app,
|
||||
generation,
|
||||
expected_generation,
|
||||
)
|
||||
.await;
|
||||
})
|
||||
},
|
||||
)?;
|
||||
if let Some(task) = previous {
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn unsubscribe_workspace(subscription: State<'_, WorkspaceSubscription>) -> Result<(), String> {
|
||||
let mut slot = subscription
|
||||
.task
|
||||
.lock()
|
||||
.map_err(|_| "Could not clear workspace subscription".to_owned())?;
|
||||
subscription.generation.invalidate()?;
|
||||
if let Some(task) = slot.take() {
|
||||
task.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let launch_args = env::args().collect::<Vec<_>>();
|
||||
let builder = tauri::Builder::default()
|
||||
.manage(Subscription(Mutex::new(None)))
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(LegacySubscription(Mutex::new(None)))
|
||||
.manage(WorkspaceSubscription {
|
||||
generation: Arc::new(bridge::SubscriptionGeneration::default()),
|
||||
task: Mutex::new(None),
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
if requested_quick_pane(&args) {
|
||||
show_quick_pane(app);
|
||||
return;
|
||||
}
|
||||
launch_new_session(app.clone(), &args);
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
||||
WindowAction::Hide => {
|
||||
let _ = window.hide();
|
||||
}
|
||||
WindowAction::ShowAndFocus => {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.setup(move |app| {
|
||||
if requested_quick_pane(&launch_args) {
|
||||
show_quick_pane(app.handle());
|
||||
} else {
|
||||
launch_new_session(app.handle().clone(), &launch_args);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_workspace,
|
||||
get_workspace_summary,
|
||||
get_model_catalog,
|
||||
create_session_runtime,
|
||||
create_quick_runtime,
|
||||
open_session_runtime,
|
||||
close_session_runtime,
|
||||
close_quick_runtime,
|
||||
list_directory_sessions,
|
||||
get_session_runtime_snapshot,
|
||||
load_ui_state,
|
||||
save_ui_state,
|
||||
subscribe_workspace,
|
||||
unsubscribe_workspace,
|
||||
list_agents,
|
||||
list_directories,
|
||||
select_worktree,
|
||||
forget_directory,
|
||||
load_agent,
|
||||
list_sessions,
|
||||
switch_session,
|
||||
@@ -139,8 +439,12 @@ pub fn run() {
|
||||
abort,
|
||||
retry,
|
||||
restart,
|
||||
renew_session,
|
||||
set_model,
|
||||
set_thinking_level,
|
||||
set_session_name,
|
||||
compact,
|
||||
pi_rpc_command,
|
||||
respond_to_extension,
|
||||
subscribe_agent
|
||||
]);
|
||||
@@ -149,3 +453,49 @@ pub fn run() {
|
||||
eprintln!("Pi Status UI exited: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialized_subscription_replacement_returns_the_previous_task() {
|
||||
let tasks = Mutex::new(None::<u64>);
|
||||
let generation = bridge::SubscriptionGeneration::default();
|
||||
assert_eq!(
|
||||
replace_subscription(&tasks, &generation, |current| current).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
replace_subscription(&tasks, &generation, |current| current).unwrap(),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(*tasks.lock().unwrap(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_session_uses_an_explicit_absolute_worktree() {
|
||||
let args = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "/workspace".into()];
|
||||
assert_eq!(requested_new_worktree_argument(&args), Some("/workspace".into()));
|
||||
let relative = vec!["pi-status-ui".into(), "--new".into(), "--worktree".into(), "workspace".into()];
|
||||
assert_eq!(requested_new_worktree_argument(&relative).filter(|path| Path::new(path).is_absolute()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_flag_is_detected_without_affecting_toggle() {
|
||||
assert!(requested_quick_pane(&["--quick".to_owned()]));
|
||||
assert!(!requested_quick_pane(&["--toggle".to_owned()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
|
||||
let toggle = vec!["--toggle".to_owned()];
|
||||
|
||||
assert_eq!(window_action(&toggle, true), WindowAction::Hide);
|
||||
assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus);
|
||||
assert_eq!(
|
||||
window_action(&["--show".to_owned()], true),
|
||||
WindowAction::ShowAndFocus
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const VERSION: u32 = 1;
|
||||
const MAX_ITEMS: usize = 256;
|
||||
const MAX_TOTAL_ITEMS: usize = 2048;
|
||||
const MAX_DRAFT_BYTES: usize = 128 * 1024;
|
||||
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
|
||||
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
|
||||
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct WorkspaceCursor {
|
||||
pub bridge_instance_id: String,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ScrollState {
|
||||
pub at_bottom: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scroll_top: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct LastSeenState {
|
||||
pub bridge_instance_id: String,
|
||||
pub seq: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_activity: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct QuickPaneSettings {
|
||||
pub workspace: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub follow_up_provider: String,
|
||||
pub follow_up_model: String,
|
||||
pub instructions: String,
|
||||
pub maximum_seconds: u32,
|
||||
pub maximum_tool_calls: u32,
|
||||
}
|
||||
|
||||
impl Default for QuickPaneSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
workspace: String::new(),
|
||||
provider: String::new(),
|
||||
model: String::new(),
|
||||
follow_up_provider: String::new(),
|
||||
follow_up_model: String::new(),
|
||||
instructions: "Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.".to_owned(),
|
||||
maximum_seconds: 60,
|
||||
maximum_tool_calls: 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UiStateV1 {
|
||||
pub version: u32,
|
||||
#[serde(default)]
|
||||
pub directory_order: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub runtime_order_by_directory: BTreeMap<String, Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub selected_runtime_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub drafts: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub scroll: BTreeMap<String, ScrollState>,
|
||||
#[serde(default)]
|
||||
pub last_seen: BTreeMap<String, LastSeenState>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_cursor: Option<WorkspaceCursor>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub interface_scale: Option<f64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_new_session_worktree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dismissed_collision_warning: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub quick_pane: QuickPaneSettings,
|
||||
}
|
||||
|
||||
impl Default for UiStateV1 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: VERSION,
|
||||
directory_order: Vec::new(),
|
||||
runtime_order_by_directory: BTreeMap::new(),
|
||||
selected_runtime_id: None,
|
||||
drafts: BTreeMap::new(),
|
||||
scroll: BTreeMap::new(),
|
||||
last_seen: BTreeMap::new(),
|
||||
workspace_cursor: None,
|
||||
interface_scale: None,
|
||||
default_new_session_worktree: None,
|
||||
dismissed_collision_warning: None,
|
||||
quick_pane: QuickPaneSettings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded(value: &str, field: &str) -> Result<(), String> {
|
||||
if value.is_empty() || value.len() > 4096 {
|
||||
Err(format!("{field} must contain 1 to 4096 bytes"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(state: &UiStateV1) -> Result<(), String> {
|
||||
if state.version != VERSION {
|
||||
return Err(format!("Unsupported UI state version: {}", state.version));
|
||||
}
|
||||
if state.directory_order.len() > MAX_ITEMS
|
||||
|| state.runtime_order_by_directory.len() > MAX_ITEMS
|
||||
|| state.drafts.len() > MAX_ITEMS
|
||||
|| state.scroll.len() > MAX_ITEMS
|
||||
|| state.last_seen.len() > MAX_ITEMS
|
||||
{
|
||||
return Err("UI state contains too many entries".to_owned());
|
||||
}
|
||||
let mut total_items = state.directory_order.len()
|
||||
+ state.runtime_order_by_directory.len()
|
||||
+ state.drafts.len()
|
||||
+ state.scroll.len()
|
||||
+ state.last_seen.len();
|
||||
let mut total_string_bytes = 0usize;
|
||||
for path in &state.directory_order {
|
||||
bounded(path, "directory path")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(path.len());
|
||||
}
|
||||
for (path, runtimes) in &state.runtime_order_by_directory {
|
||||
bounded(path, "directory path")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(path.len());
|
||||
if runtimes.len() > MAX_ITEMS {
|
||||
return Err("UI state contains too many runtime entries".to_owned());
|
||||
}
|
||||
total_items = total_items.saturating_add(runtimes.len());
|
||||
for runtime_id in runtimes {
|
||||
bounded(runtime_id, "runtime id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
|
||||
}
|
||||
}
|
||||
if total_items > MAX_TOTAL_ITEMS {
|
||||
return Err("UI state contains too many aggregate entries".to_owned());
|
||||
}
|
||||
if let Some(runtime_id) = &state.selected_runtime_id {
|
||||
bounded(runtime_id, "selected runtime id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
|
||||
}
|
||||
if let Some(worktree) = &state.default_new_session_worktree {
|
||||
bounded(worktree, "default new-session worktree")?;
|
||||
if !Path::new(worktree).is_absolute() {
|
||||
return Err("Default new-session worktree must be absolute".to_owned());
|
||||
}
|
||||
total_string_bytes = total_string_bytes.saturating_add(worktree.len());
|
||||
}
|
||||
let mut total_draft_bytes = 0usize;
|
||||
for (runtime_id, draft) in &state.drafts {
|
||||
bounded(runtime_id, "draft runtime id")?;
|
||||
if draft.len() > MAX_DRAFT_BYTES {
|
||||
return Err("A UI draft exceeds the size limit".to_owned());
|
||||
}
|
||||
total_draft_bytes = total_draft_bytes.saturating_add(draft.len());
|
||||
total_string_bytes = total_string_bytes
|
||||
.saturating_add(runtime_id.len())
|
||||
.saturating_add(draft.len());
|
||||
}
|
||||
if total_draft_bytes > MAX_TOTAL_DRAFT_BYTES {
|
||||
return Err("UI drafts exceed the total size limit".to_owned());
|
||||
}
|
||||
for (runtime_id, scroll) in &state.scroll {
|
||||
bounded(runtime_id, "runtime id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
|
||||
if scroll
|
||||
.scroll_top
|
||||
.is_some_and(|value| !value.is_finite() || value < 0.0)
|
||||
{
|
||||
return Err("Scroll position is invalid".to_owned());
|
||||
}
|
||||
}
|
||||
for (runtime_id, last_seen) in &state.last_seen {
|
||||
bounded(runtime_id, "runtime id")?;
|
||||
bounded(
|
||||
&last_seen.bridge_instance_id,
|
||||
"last-seen bridge instance id",
|
||||
)?;
|
||||
total_string_bytes = total_string_bytes
|
||||
.saturating_add(runtime_id.len())
|
||||
.saturating_add(last_seen.bridge_instance_id.len());
|
||||
if let Some(activity) = &last_seen.last_activity {
|
||||
bounded(activity, "last activity")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(activity.len());
|
||||
}
|
||||
}
|
||||
if let Some(cursor) = &state.workspace_cursor {
|
||||
bounded(&cursor.bridge_instance_id, "bridge instance id")?;
|
||||
total_string_bytes = total_string_bytes.saturating_add(cursor.bridge_instance_id.len());
|
||||
}
|
||||
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
|
||||
return Err("UI state strings exceed the total size limit".to_owned());
|
||||
}
|
||||
let quick = &state.quick_pane;
|
||||
let quick_is_default = quick.workspace.is_empty()
|
||||
&& quick.provider.is_empty()
|
||||
&& quick.model.is_empty()
|
||||
&& quick.follow_up_provider.is_empty()
|
||||
&& quick.follow_up_model.is_empty();
|
||||
if !quick_is_default {
|
||||
bounded(&quick.workspace, "quick-pane workspace")?;
|
||||
if !Path::new(&quick.workspace).is_absolute() || !Path::new(&quick.workspace).is_dir() {
|
||||
return Err("Quick-pane workspace must be an existing absolute directory".to_owned());
|
||||
}
|
||||
bounded(&quick.provider, "quick-pane provider")?;
|
||||
bounded(&quick.model, "quick-pane model")?;
|
||||
bounded(&quick.follow_up_provider, "quick-pane follow-up provider")?;
|
||||
bounded(&quick.follow_up_model, "quick-pane follow-up model")?;
|
||||
}
|
||||
if quick.instructions.is_empty() || quick.instructions.len() > 4096 {
|
||||
return Err("Quick-pane instructions must contain 1 to 4096 bytes".to_owned());
|
||||
}
|
||||
if !(1..=600).contains(&quick.maximum_seconds) || !(1..=64).contains(&quick.maximum_tool_calls)
|
||||
{
|
||||
return Err("Quick-pane limits are outside the supported range".to_owned());
|
||||
}
|
||||
total_string_bytes = total_string_bytes
|
||||
.saturating_add(quick.workspace.len())
|
||||
.saturating_add(quick.provider.len())
|
||||
.saturating_add(quick.model.len())
|
||||
.saturating_add(quick.follow_up_provider.len())
|
||||
.saturating_add(quick.follow_up_model.len())
|
||||
.saturating_add(quick.instructions.len());
|
||||
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
|
||||
return Err("UI state strings exceed the total size limit".to_owned());
|
||||
}
|
||||
if let Some(scale) = state.interface_scale {
|
||||
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
|
||||
return Err("Interface scale is outside the supported range".to_owned());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn state_path(app_data_dir: &Path) -> PathBuf {
|
||||
app_data_dir.join("ui-workspace-v1.json")
|
||||
}
|
||||
|
||||
pub fn load_from(app_data_dir: &Path) -> Result<UiStateV1, String> {
|
||||
let path = state_path(app_data_dir);
|
||||
let mut file = match fs::File::open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(UiStateV1::default())
|
||||
}
|
||||
Err(error) => return Err(format!("Could not read UI state: {error}")),
|
||||
};
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("Could not inspect UI state: {error}"))?;
|
||||
if metadata.len() > MAX_FILE_BYTES as u64 {
|
||||
return Err("UI state file exceeds the size limit".to_owned());
|
||||
}
|
||||
let mut content = Vec::with_capacity(metadata.len() as usize);
|
||||
Read::by_ref(&mut file)
|
||||
.take((MAX_FILE_BYTES + 1) as u64)
|
||||
.read_to_end(&mut content)
|
||||
.map_err(|error| format!("Could not read UI state: {error}"))?;
|
||||
if content.len() > MAX_FILE_BYTES {
|
||||
return Err("UI state file exceeds the size limit".to_owned());
|
||||
}
|
||||
let state: UiStateV1 = serde_json::from_slice(&content)
|
||||
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
|
||||
validate(&state)?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn save_to(app_data_dir: &Path, state: &UiStateV1) -> Result<(), String> {
|
||||
validate(state)?;
|
||||
fs::create_dir_all(app_data_dir)
|
||||
.map_err(|error| format!("Could not create app data: {error}"))?;
|
||||
set_mode(app_data_dir, 0o700)?;
|
||||
let target = state_path(app_data_dir);
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let temporary = app_data_dir.join(format!(".ui-workspace-{}-{nonce}.tmp", std::process::id()));
|
||||
let encoded = serde_json::to_vec_pretty(state).map_err(|error| error.to_string())?;
|
||||
if encoded.len().saturating_add(1) > MAX_FILE_BYTES {
|
||||
return Err("UI state file exceeds the size limit".to_owned());
|
||||
}
|
||||
let result = (|| {
|
||||
let mut file = create_private_file(&temporary)?;
|
||||
file.write_all(&encoded)
|
||||
.map_err(|error| format!("Could not write UI state: {error}"))?;
|
||||
file.write_all(b"\n")
|
||||
.map_err(|error| format!("Could not write UI state: {error}"))?;
|
||||
file.sync_all()
|
||||
.map_err(|error| format!("Could not sync UI state: {error}"))?;
|
||||
fs::rename(&temporary, &target)
|
||||
.map_err(|error| format!("Could not replace UI state: {error}"))?;
|
||||
set_mode(&target, 0o600)?;
|
||||
sync_directory(app_data_dir)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_directory(path: &Path) -> Result<(), String> {
|
||||
let directory = OpenOptions::new()
|
||||
.read(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Could not open app data directory for sync: {error}"))?;
|
||||
directory
|
||||
.sync_all()
|
||||
.map_err(|error| format!("Could not sync app data directory: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_directory(_path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn create_private_file(path: &Path) -> Result<std::fs::File, String> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Could not create temporary UI state: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn create_private_file(path: &Path) -> Result<std::fs::File, String> {
|
||||
OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("Could not create temporary UI state: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<(), String> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|error| format!("Could not secure UI state path: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_mode(_path: &Path, _mode: u32) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
"pi-ui-state-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_only_round_trip_and_missing_default() {
|
||||
let root = temp_dir("roundtrip");
|
||||
assert_eq!(load_from(&root).unwrap(), UiStateV1::default());
|
||||
let mut state = UiStateV1 {
|
||||
selected_runtime_id: Some("runtime-1".into()),
|
||||
interface_scale: Some(1.1),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
state.drafts.insert("runtime-1".into(), "draft".into());
|
||||
save_to(&root, &state).unwrap();
|
||||
assert_eq!(load_from(&root).unwrap(), state);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
fs::metadata(&root).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(state_path(&root))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn temporary_files(root: &Path) -> Vec<PathBuf> {
|
||||
fs::read_dir(root)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.starts_with(".ui-workspace-") && name.ends_with(".tmp")
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_pane_settings_validate_complete_existing_workspace() {
|
||||
let root = temp_dir("quick-pane");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let mut state = UiStateV1::default();
|
||||
state.quick_pane.workspace = root.to_string_lossy().into_owned();
|
||||
state.quick_pane.provider = "provider".into();
|
||||
state.quick_pane.model = "quick-model".into();
|
||||
state.quick_pane.follow_up_provider = "provider".into();
|
||||
state.quick_pane.follow_up_model = "follow-up-model".into();
|
||||
assert!(validate(&state).is_ok());
|
||||
state.quick_pane.maximum_seconds = 0;
|
||||
assert!(validate(&state).is_err());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
|
||||
let root = temp_dir("replace");
|
||||
let first = UiStateV1 {
|
||||
selected_runtime_id: Some("runtime-1".into()),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
let second = UiStateV1 {
|
||||
selected_runtime_id: Some("runtime-2".into()),
|
||||
interface_scale: Some(1.2),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
save_to(&root, &first).unwrap();
|
||||
save_to(&root, &second).unwrap();
|
||||
assert_eq!(load_from(&root).unwrap(), second);
|
||||
assert!(temporary_files(&root).is_empty());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_corruption_versions_unknown_fields_and_limits_without_overwrite() {
|
||||
let root = temp_dir("invalid");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let path = state_path(&root);
|
||||
for invalid in [
|
||||
"{broken",
|
||||
r#"{"version":2}"#,
|
||||
r#"{"version":1,"transcript":[]}"#,
|
||||
] {
|
||||
fs::write(&path, invalid).unwrap();
|
||||
assert!(load_from(&root).is_err());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), invalid);
|
||||
}
|
||||
|
||||
let baseline = UiStateV1 {
|
||||
selected_runtime_id: Some("baseline".into()),
|
||||
..UiStateV1::default()
|
||||
};
|
||||
save_to(&root, &baseline).unwrap();
|
||||
let original = fs::read(&path).unwrap();
|
||||
let mut oversized = UiStateV1::default();
|
||||
oversized
|
||||
.drafts
|
||||
.insert("runtime".into(), "x".repeat(MAX_DRAFT_BYTES + 1));
|
||||
assert!(save_to(&root, &oversized).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
assert!(temporary_files(&root).is_empty());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_aggregate_and_raw_file_size_limits() {
|
||||
let root = temp_dir("aggregate");
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let mut aggregate = UiStateV1::default();
|
||||
for directory in 0..9 {
|
||||
aggregate.runtime_order_by_directory.insert(
|
||||
format!("/directory-{directory}"),
|
||||
(0..MAX_ITEMS)
|
||||
.map(|runtime| format!("runtime-{directory}-{runtime}"))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
assert!(validate(&aggregate)
|
||||
.unwrap_err()
|
||||
.contains("aggregate entries"));
|
||||
|
||||
let path = state_path(&root);
|
||||
fs::write(&path, vec![b' '; MAX_FILE_BYTES + 1]).unwrap();
|
||||
assert!(load_from(&root)
|
||||
.unwrap_err()
|
||||
.contains("file exceeds the size limit"));
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().len(),
|
||||
(MAX_FILE_BYTES + 1) as u64
|
||||
);
|
||||
assert!(temporary_files(&root).is_empty());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,32 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Pi Status UI",
|
||||
"width": 760,
|
||||
"height": 620,
|
||||
"minWidth": 480,
|
||||
"minHeight": 420,
|
||||
"width": 1100,
|
||||
"height": 840,
|
||||
"minWidth": 640,
|
||||
"minHeight": 520,
|
||||
"center": true,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"shadow": true
|
||||
},
|
||||
{
|
||||
"label": "quick-pane",
|
||||
"title": "Pi Quick Pane",
|
||||
"width": 520,
|
||||
"height": 360,
|
||||
"minWidth": 400,
|
||||
"minHeight": 260,
|
||||
"center": true,
|
||||
"visible": false,
|
||||
"decorations": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"shadow": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+1144
-594
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,396 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeSummary, UiState, Workspace } from "./workspace/types";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
workspaceReducer,
|
||||
} from "./workspace/reducer";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
loadSnapshot: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
useWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({ invoke: mocks.invoke }));
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: () => ({ startDragging: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
|
||||
vi.mock("./workspace/useWorkspace", () => ({
|
||||
useWorkspace: mocks.useWorkspace,
|
||||
}));
|
||||
|
||||
import App from "./App";
|
||||
|
||||
const summary: RuntimeSummary = {
|
||||
runtimeId: "runtime-a",
|
||||
agentId: "agent-a",
|
||||
worktreePath: "/repo",
|
||||
state: "idle",
|
||||
label: "Session A",
|
||||
attention: false,
|
||||
queueCount: 0,
|
||||
lastActivity: "2026-08-24T20:00:00.000Z",
|
||||
openedAt: "2026-08-24T20:00:00.000Z",
|
||||
};
|
||||
const otherSummary: RuntimeSummary = {
|
||||
...summary,
|
||||
runtimeId: "runtime-b",
|
||||
agentId: "agent-b",
|
||||
label: "Session B",
|
||||
};
|
||||
const uiState: UiState = {
|
||||
version: 1,
|
||||
directoryOrder: [],
|
||||
runtimeOrderByDirectory: {},
|
||||
drafts: {},
|
||||
scroll: {},
|
||||
lastSeen: {},
|
||||
};
|
||||
|
||||
function workspaceState() {
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: true,
|
||||
openCount: 2,
|
||||
workingCount: 0,
|
||||
attentionCount: 0,
|
||||
recoveringCount: 0,
|
||||
errorCount: 0,
|
||||
runtimes: [summary, otherSummary],
|
||||
},
|
||||
],
|
||||
};
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById[summary.runtimeId].models = [
|
||||
{ provider: "openai", id: "gpt-5", name: "GPT 5" },
|
||||
];
|
||||
return state;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("App command progress and errors", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.invoke.mockResolvedValue(uiState);
|
||||
mocks.refresh.mockResolvedValue(undefined);
|
||||
mocks.loadSnapshot.mockResolvedValue(true);
|
||||
mocks.useWorkspace.mockReturnValue({
|
||||
state: workspaceState(),
|
||||
dispatch: mocks.dispatch,
|
||||
refresh: mocks.refresh,
|
||||
loadSnapshot: mocks.loadSnapshot,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows explicit Pi readiness in the compact session status", () => {
|
||||
render(<App />);
|
||||
const status = screen.getByRole("status", { name: "Session status" });
|
||||
expect(status).toHaveTextContent("Pi ready");
|
||||
expect(status).toHaveTextContent("Ready for your next prompt");
|
||||
});
|
||||
|
||||
it("shows restart progress until the replacement snapshot is restored", async () => {
|
||||
const user = userEvent.setup();
|
||||
const restart = deferred<unknown>();
|
||||
const snapshot = deferred<boolean>();
|
||||
mocks.invoke.mockImplementation((command: string) =>
|
||||
command === "restart" ? restart.promise : Promise.resolve(undefined),
|
||||
);
|
||||
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Controls" }));
|
||||
await user.click(screen.getByRole("button", { name: "Restart Pi" }));
|
||||
|
||||
const restarting = screen.getByRole("button", { name: "Restarting Pi…" });
|
||||
expect(restarting).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("Restoring this session and its extensions…"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "true");
|
||||
await user.click(restarting);
|
||||
expect(mocks.invoke).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(1, "load_ui_state");
|
||||
expect(mocks.invoke).toHaveBeenNthCalledWith(2, "restart", {
|
||||
agentId: "agent-a",
|
||||
});
|
||||
|
||||
await act(async () => restart.resolve(undefined));
|
||||
expect(screen.getByRole("button", { name: "Restarting Pi…" })).toBeDisabled();
|
||||
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
await act(async () => snapshot.resolve(true));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Restart Pi" })).toBeEnabled(),
|
||||
);
|
||||
expect(screen.getByRole("main")).toHaveAttribute("aria-busy", "false");
|
||||
});
|
||||
|
||||
it("renews a background session from its tab without changing selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
const renewal = deferred<unknown>();
|
||||
const snapshot = deferred<boolean>();
|
||||
mocks.invoke.mockImplementation((command: string) =>
|
||||
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
|
||||
);
|
||||
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
||||
render(<App />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Renew Session B" }));
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("renew_session", {
|
||||
agentId: "agent-b",
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Renewing Session B" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("tab", { name: /Session A/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
|
||||
await act(async () => renewal.resolve(undefined));
|
||||
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-b", {
|
||||
throwOnError: true,
|
||||
});
|
||||
await act(async () => snapshot.resolve(true));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Renew Session B" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps parallel prompt submission state attached to its session", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submissionA = deferred<{ delivery: "prompt" }>();
|
||||
const submissionB = deferred<{ delivery: "prompt" }>();
|
||||
let state = workspaceState();
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "runtime-a",
|
||||
draft: "Prompt A",
|
||||
});
|
||||
mocks.dispatch.mockImplementation((action) => {
|
||||
state = workspaceReducer(state, action);
|
||||
});
|
||||
mocks.useWorkspace.mockImplementation(() => ({
|
||||
state,
|
||||
dispatch: mocks.dispatch,
|
||||
refresh: mocks.refresh,
|
||||
loadSnapshot: mocks.loadSnapshot,
|
||||
}));
|
||||
mocks.invoke.mockImplementation(
|
||||
(command: string, args?: { agentId?: string }) => {
|
||||
if (command !== "submit_prompt") return Promise.resolve(undefined);
|
||||
return args?.agentId === "agent-a"
|
||||
? submissionA.promise
|
||||
: submissionB.promise;
|
||||
},
|
||||
);
|
||||
const rendered = render(<App />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
state = workspaceReducer(state, {
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-b",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "runtime-b",
|
||||
draft: "Prompt B",
|
||||
});
|
||||
rendered.rerender(<App />);
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
state = workspaceReducer(state, {
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-a",
|
||||
});
|
||||
rendered.rerender(<App />);
|
||||
|
||||
expect(screen.getByLabelText("Prompt")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Sending…" })).toBeDisabled();
|
||||
expect(screen.getByText("Sending your prompt…")).toBeInTheDocument();
|
||||
const sessionStatus = screen.getByRole("status", { name: "Session status" });
|
||||
expect(sessionStatus).toHaveTextContent("Working");
|
||||
expect(sessionStatus).toHaveTextContent("Sending prompt to Pi…");
|
||||
expect(sessionStatus).toHaveTextContent("Bridge");
|
||||
expect(sessionStatus).toHaveTextContent("ready");
|
||||
expect(sessionStatus).toHaveClass("working");
|
||||
expect(rendered.container.querySelector(".status-row")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
submissionA.resolve({ delivery: "prompt" });
|
||||
submissionB.resolve({ delivery: "prompt" });
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps prompt failures attached to the session that submitted them", async () => {
|
||||
const user = userEvent.setup();
|
||||
const submission = deferred<{ delivery: "prompt" }>();
|
||||
let state = workspaceState();
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "runtime-a",
|
||||
draft: "Fail in A",
|
||||
});
|
||||
mocks.dispatch.mockImplementation((action) => {
|
||||
state = workspaceReducer(state, action);
|
||||
});
|
||||
mocks.useWorkspace.mockImplementation(() => ({
|
||||
state,
|
||||
dispatch: mocks.dispatch,
|
||||
refresh: mocks.refresh,
|
||||
loadSnapshot: mocks.loadSnapshot,
|
||||
}));
|
||||
mocks.invoke.mockReturnValue(submission.promise);
|
||||
const rendered = render(<App />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Send" }));
|
||||
state = workspaceReducer(state, {
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-b",
|
||||
});
|
||||
rendered.rerender(<App />);
|
||||
await act(async () => submission.reject(new Error("session A failed")));
|
||||
|
||||
expect(
|
||||
screen.queryByText("Pi command failed: Error: session A failed"),
|
||||
).toBeNull();
|
||||
state = workspaceReducer(state, {
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-a",
|
||||
});
|
||||
rendered.rerender(<App />);
|
||||
expect(
|
||||
screen.getByText("Pi command failed: Error: session A failed"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps command forms bound to the session that opened them", async () => {
|
||||
const user = userEvent.setup();
|
||||
let state = workspaceState();
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "runtime-a",
|
||||
draft: "/bash",
|
||||
});
|
||||
mocks.dispatch.mockImplementation((action) => {
|
||||
state = workspaceReducer(state, action);
|
||||
});
|
||||
mocks.useWorkspace.mockImplementation(() => ({
|
||||
state,
|
||||
dispatch: mocks.dispatch,
|
||||
refresh: mocks.refresh,
|
||||
loadSnapshot: mocks.loadSnapshot,
|
||||
}));
|
||||
mocks.invoke.mockResolvedValue(undefined);
|
||||
const rendered = render(<App />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /\/bash/ }));
|
||||
expect(screen.getByRole("dialog", { name: "/bash" })).toBeInTheDocument();
|
||||
state = workspaceReducer(state, {
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-b",
|
||||
});
|
||||
rendered.rerender(<App />);
|
||||
expect(screen.queryByRole("dialog", { name: "/bash" })).toBeNull();
|
||||
|
||||
state = workspaceReducer(state, {
|
||||
type: "selectRuntime",
|
||||
runtimeId: "runtime-a",
|
||||
});
|
||||
rendered.rerender(<App />);
|
||||
await user.type(screen.getByLabelText("command"), "echo session-a");
|
||||
await user.click(screen.getByRole("button", { name: "Run command" }));
|
||||
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("pi_rpc_command", {
|
||||
agentId: "agent-a",
|
||||
command: "bash",
|
||||
input: { command: "echo session-a" },
|
||||
});
|
||||
});
|
||||
|
||||
it("locks selected-session input and output throughout renewal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const renewal = deferred<unknown>();
|
||||
const snapshot = deferred<boolean>();
|
||||
mocks.invoke.mockImplementation((command: string) =>
|
||||
command === "renew_session" ? renewal.promise : Promise.resolve(undefined),
|
||||
);
|
||||
mocks.loadSnapshot.mockReturnValue(snapshot.promise);
|
||||
render(<App />);
|
||||
const prompt = screen.getByLabelText("Prompt");
|
||||
expect(prompt).toBeEnabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Renew Session A" }));
|
||||
expect(prompt).toBeDisabled();
|
||||
expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-busy", "true");
|
||||
expect(
|
||||
screen.getByText("Input and output are temporarily locked."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen
|
||||
.getByRole("region", { name: "Conversation" })
|
||||
.closest(".workflow-main"),
|
||||
).toHaveAttribute("inert");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Model Current model/i }),
|
||||
).toBeDisabled();
|
||||
|
||||
await act(async () => renewal.resolve(undefined));
|
||||
expect(prompt).toBeDisabled();
|
||||
expect(mocks.loadSnapshot).toHaveBeenCalledWith("runtime-a", {
|
||||
throwOnError: true,
|
||||
});
|
||||
await act(async () => snapshot.resolve(true));
|
||||
await waitFor(() => expect(prompt).toBeEnabled());
|
||||
expect(
|
||||
screen.queryByText("Input and output are temporarily locked."),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the model picker open and shows a persistent alert on RPC failure", async () => {
|
||||
const user = userEvent.setup();
|
||||
mocks.invoke.mockRejectedValue(new Error("provider rejected the model"));
|
||||
render(<App />);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /Model Current model/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("option", { name: /GPT 5/ }));
|
||||
|
||||
expect(mocks.invoke).toHaveBeenCalledWith("set_model", {
|
||||
agentId: "agent-a",
|
||||
provider: "openai",
|
||||
modelId: "gpt-5",
|
||||
});
|
||||
const modelError = await screen.findByText(
|
||||
"Pi model command failed: Error: provider rejected the model",
|
||||
);
|
||||
expect(modelError.closest('[role="alert"]')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Choose model" }),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.loadSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+1028
-1173
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
export type RpcField = {
|
||||
name: string;
|
||||
label: string;
|
||||
kind: "text" | "path" | "boolean" | "enum" | "json";
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
};
|
||||
|
||||
export type RpcCommand = {
|
||||
command: string;
|
||||
category:
|
||||
| "Prompting"
|
||||
| "Session"
|
||||
| "Model & Thinking"
|
||||
| "Queue"
|
||||
| "Context & Retry"
|
||||
| "Shell"
|
||||
| "Inspection & Export";
|
||||
description: string;
|
||||
input?: RpcField[];
|
||||
};
|
||||
|
||||
const text = (name: string, required = true): RpcField => ({
|
||||
name,
|
||||
label: name,
|
||||
kind: "text",
|
||||
required,
|
||||
});
|
||||
const path = (name: string, required = true): RpcField => ({
|
||||
name,
|
||||
label: name,
|
||||
kind: "path",
|
||||
required,
|
||||
});
|
||||
const boolean = (name: string): RpcField => ({
|
||||
name,
|
||||
label: name,
|
||||
kind: "boolean",
|
||||
required: true,
|
||||
});
|
||||
const enumField = (name: string, options: string[]): RpcField => ({
|
||||
name,
|
||||
label: name,
|
||||
kind: "enum",
|
||||
options,
|
||||
required: true,
|
||||
});
|
||||
const json = (name: string): RpcField => ({ name, label: name, kind: "json" });
|
||||
|
||||
const define = (
|
||||
category: RpcCommand["category"],
|
||||
commands: Array<[string, string, RpcField[]?]>,
|
||||
) =>
|
||||
commands.map(([command, description, input]) => ({
|
||||
command,
|
||||
category,
|
||||
description,
|
||||
...(input ? { input } : {}),
|
||||
}));
|
||||
|
||||
export const rpcCommandCatalog: RpcCommand[] = [
|
||||
...define("Prompting", [
|
||||
[
|
||||
"prompt",
|
||||
"Send a prompt",
|
||||
[
|
||||
text("message"),
|
||||
json("images"),
|
||||
enumField("streamingBehavior", ["steer", "followUp"]),
|
||||
],
|
||||
],
|
||||
["steer", "Queue a steering message", [text("message"), json("images")]],
|
||||
[
|
||||
"follow_up",
|
||||
"Queue a follow-up message",
|
||||
[text("message"), json("images")],
|
||||
],
|
||||
["abort", "Abort the agent"],
|
||||
]),
|
||||
...define("Session", [
|
||||
["new_session", "Start a new session", [path("parentSession", false)]],
|
||||
["switch_session", "Switch session", [path("sessionPath")]],
|
||||
["fork", "Fork from a message", [text("entryId")]],
|
||||
["clone", "Clone the active branch"],
|
||||
["set_session_name", "Set session name", [text("name")]],
|
||||
["get_fork_messages", "List forkable messages"],
|
||||
["get_entries", "Read session entries", [text("since", false)]],
|
||||
["get_tree", "Read session tree"],
|
||||
["get_state", "Read session state"],
|
||||
["get_messages", "Read messages"],
|
||||
["get_last_assistant_text", "Read last assistant text"],
|
||||
]),
|
||||
...define("Model & Thinking", [
|
||||
["get_available_models", "List models"],
|
||||
["set_model", "Select model", [text("provider"), text("modelId")]],
|
||||
["cycle_model", "Cycle model"],
|
||||
[
|
||||
"set_thinking_level",
|
||||
"Set thinking level",
|
||||
[
|
||||
enumField("level", [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
]),
|
||||
],
|
||||
],
|
||||
["cycle_thinking_level", "Cycle thinking level"],
|
||||
]),
|
||||
...define("Queue", [
|
||||
[
|
||||
"set_steering_mode",
|
||||
"Set steering delivery",
|
||||
[enumField("mode", ["all", "one-at-a-time"])],
|
||||
],
|
||||
[
|
||||
"set_follow_up_mode",
|
||||
"Set follow-up delivery",
|
||||
[enumField("mode", ["all", "one-at-a-time"])],
|
||||
],
|
||||
]),
|
||||
...define("Context & Retry", [
|
||||
["compact", "Compact context", [text("customInstructions", false)]],
|
||||
["set_auto_compaction", "Set automatic compaction", [boolean("enabled")]],
|
||||
["set_auto_retry", "Set automatic retry", [boolean("enabled")]],
|
||||
["abort_retry", "Abort retry"],
|
||||
]),
|
||||
...define("Shell", [
|
||||
["bash", "Run shell command", [text("command")]],
|
||||
["abort_bash", "Abort shell command"],
|
||||
]),
|
||||
...define("Inspection & Export", [
|
||||
["get_session_stats", "Read session statistics"],
|
||||
["export_html", "Export session HTML", [path("outputPath", false)]],
|
||||
["get_commands", "List Pi commands"],
|
||||
]),
|
||||
];
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { RpcCommand, RpcField } from "../commands/rpc";
|
||||
|
||||
const controls = (container: HTMLElement | null) =>
|
||||
Array.from(
|
||||
container?.querySelectorAll<HTMLElement>("button, input, select, textarea") ??
|
||||
[],
|
||||
).filter((node) => !node.hasAttribute("disabled"));
|
||||
|
||||
function valueFor(field: RpcField, value: string | boolean): unknown {
|
||||
if (field.kind === "boolean") return value === true;
|
||||
if (field.kind === "json")
|
||||
return value ? JSON.parse(value as string) : undefined;
|
||||
return value || undefined;
|
||||
}
|
||||
|
||||
export function CommandFormDialog({
|
||||
command,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
pending = false,
|
||||
commandError,
|
||||
}: {
|
||||
command?: RpcCommand;
|
||||
onSubmit: (input: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
pending?: boolean;
|
||||
commandError?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string | boolean>>({});
|
||||
const [validationError, setValidationError] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!command) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValues({});
|
||||
setValidationError(undefined);
|
||||
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [command]);
|
||||
if (!command) return null;
|
||||
const fields = command.input ?? [];
|
||||
const submit = () => {
|
||||
if (pending) return;
|
||||
try {
|
||||
const input: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const value = valueFor(
|
||||
field,
|
||||
values[field.name] ?? (field.kind === "boolean" ? false : ""),
|
||||
);
|
||||
if (field.required && (value === undefined || value === ""))
|
||||
throw new Error(`${field.label} is required`);
|
||||
if (value !== undefined) input[field.name] = value;
|
||||
}
|
||||
onSubmit(input);
|
||||
} catch (cause) {
|
||||
setValidationError(cause instanceof Error ? cause.message : "Invalid input");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="extension-backdrop">
|
||||
<section
|
||||
ref={ref}
|
||||
className="extension"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="command-form-title"
|
||||
aria-busy={pending}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
const items = controls(ref.current),
|
||||
first = items[0],
|
||||
last = items[items.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="extension-eyebrow">Pi RPC command</p>
|
||||
<h2 id="command-form-title">/{command.command}</h2>
|
||||
<p>{command.description}</p>
|
||||
{fields.map((field) => (
|
||||
<label key={field.name}>
|
||||
{field.label}
|
||||
{field.kind === "boolean" ? (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
type="checkbox"
|
||||
checked={values[field.name] === true}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : field.kind === "enum" ? (
|
||||
<select
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.kind === "json" ? (
|
||||
<textarea
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-label={field.label}
|
||||
value={(values[field.name] as string) ?? ""}
|
||||
disabled={pending}
|
||||
onChange={(event) =>
|
||||
setValues({
|
||||
...values,
|
||||
[field.name]: event.currentTarget.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
{(validationError || commandError) && (
|
||||
<p role="alert">{validationError || commandError}</p>
|
||||
)}
|
||||
<div className="extension-options">
|
||||
<button disabled={pending} onClick={submit}>
|
||||
{pending ? "Running…" : "Run command"}
|
||||
</button>
|
||||
<button className="quiet" disabled={pending} onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RpcCommand } from "../commands/rpc";
|
||||
|
||||
export function CommandPalette({
|
||||
draft,
|
||||
commands,
|
||||
onSelect,
|
||||
}: {
|
||||
draft: string;
|
||||
commands: RpcCommand[];
|
||||
onSelect: (command: RpcCommand) => void;
|
||||
}) {
|
||||
const [active, setActive] = useState(0);
|
||||
const query = draft.startsWith("/") ? draft.slice(1).toLowerCase() : "";
|
||||
const matches = draft.startsWith("/")
|
||||
? commands.filter((command) =>
|
||||
`${command.command} ${command.category} ${command.description}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
)
|
||||
: [];
|
||||
useEffect(() => setActive(0), [draft]);
|
||||
useEffect(() => {
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (!matches.length) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActive((current) => (current + 1) % matches.length);
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActive((current) => (current - 1 + matches.length) % matches.length);
|
||||
}
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
document.activeElement?.getAttribute("aria-label") === "Prompt"
|
||||
) {
|
||||
event.preventDefault();
|
||||
onSelect(matches[active]);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => window.removeEventListener("keydown", keydown);
|
||||
}, [active, matches, onSelect]);
|
||||
if (!draft.startsWith("/")) return null;
|
||||
return (
|
||||
<section className="command-palette" aria-label="Pi RPC commands">
|
||||
{matches.map((command, index) => (
|
||||
<button
|
||||
key={command.command}
|
||||
className={index === active ? "active" : ""}
|
||||
onClick={() => onSelect(command)}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
>
|
||||
<strong>/{command.command}</strong>
|
||||
<span>
|
||||
{command.category} · {command.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{matches.length === 0 && <p className="muted">No RPC commands match.</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
|
||||
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
|
||||
import { CommandPalette } from "./CommandPalette";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
type SessionStatus = {
|
||||
label: string;
|
||||
detail: string;
|
||||
phase: RuntimeView["progress"]["phase"];
|
||||
bridge: string;
|
||||
extensions: string[];
|
||||
};
|
||||
|
||||
export function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return (message.content ?? [])
|
||||
.map((part) => {
|
||||
if (part.type === "text") return part.text ?? "";
|
||||
if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`;
|
||||
if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
function visibleLines(text: string) {
|
||||
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
||||
while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
function compactCount(value: number) {
|
||||
const units = [
|
||||
{ threshold: 1_000_000_000, divisor: 1_000_000_000, suffix: "b" },
|
||||
{ threshold: 1_000_000, divisor: 1_000_000, suffix: "m" },
|
||||
{ threshold: 1_000, divisor: 1_000, suffix: "k" },
|
||||
];
|
||||
const unit = units.find((candidate) => Math.abs(value) >= candidate.threshold);
|
||||
if (!unit) return String(value);
|
||||
const scaled = Math.round((value / unit.divisor) * 10) / 10;
|
||||
return `${scaled}${unit.suffix}`;
|
||||
}
|
||||
|
||||
function runtimeStatusLabel(isWorking: boolean, state: string) {
|
||||
if (isWorking) return "Working";
|
||||
return state === "idle" ? "Pi ready" : state;
|
||||
}
|
||||
|
||||
function plainStatusText(value: string) {
|
||||
return value
|
||||
.normalize("NFC")
|
||||
.replace(/\u001B\[[0-?]*[ -/]*[@-~]/gu, "")
|
||||
.replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/gu, "")
|
||||
.replace(
|
||||
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uE000-\uF8FF\uFEFF\uFFFD]/gu,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function TranscriptMessage({
|
||||
message,
|
||||
}: {
|
||||
message: RuntimeView["messages"][number];
|
||||
}) {
|
||||
const role = message.role ?? "system";
|
||||
if (role === "toolResult") {
|
||||
const text = messageText(message);
|
||||
const lines = visibleLines(text);
|
||||
const label = `Tool result · ${message.toolName ?? "tool"}`;
|
||||
if (lines.length <= 2)
|
||||
return (
|
||||
<article className={`message ${role} tool-result-visible`}>
|
||||
<strong>{label}</strong>
|
||||
<pre>{text}</pre>
|
||||
</article>
|
||||
);
|
||||
return (
|
||||
<article className={`message ${role}`}>
|
||||
<details className="tool-result">
|
||||
<summary>
|
||||
<span className="tool-result-label">{label}</span>
|
||||
<span className="tool-result-preview">
|
||||
{lines.slice(0, 2).join("\n")}
|
||||
</span>
|
||||
<span className="tool-result-hidden-hint">
|
||||
{lines.length - 2} more {lines.length - 2 === 1 ? "line" : "lines"}{" "}
|
||||
hidden · Expand to view
|
||||
</span>
|
||||
</summary>
|
||||
<pre className="tool-result-full">{text}</pre>
|
||||
</details>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<article className={`message ${role}`}>
|
||||
<strong>{message.role ?? "message"}</strong>
|
||||
<pre>{messageText(message)}</pre>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function pendingStatus(pending: RuntimeView["pendingSubmissions"][number]) {
|
||||
if (pending.phase === "sending")
|
||||
return pending.delivery === "follow_up"
|
||||
? "Queueing follow-up…"
|
||||
: "Sending prompt…";
|
||||
return pending.delivery === "follow_up"
|
||||
? "Follow-up queued · waiting for Pi"
|
||||
: "Prompt accepted · waiting for Pi";
|
||||
}
|
||||
|
||||
function todos(messages: RuntimeView["messages"]) {
|
||||
const order: Record<TodoTask["status"], number> = {
|
||||
in_progress: 0,
|
||||
pending: 1,
|
||||
completed: 2,
|
||||
deleted: 3,
|
||||
};
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
if (
|
||||
messages[index].role === "toolResult" &&
|
||||
messages[index].toolName === "todo" &&
|
||||
Array.isArray(messages[index].details?.tasks)
|
||||
)
|
||||
return messages[index]
|
||||
.details!.tasks!.filter((task) => task.status !== "deleted")
|
||||
.sort((a, b) => order[a.status] - order[b.status]);
|
||||
}
|
||||
return [] as TodoTask[];
|
||||
}
|
||||
export function ConversationWorkspace({
|
||||
runtime,
|
||||
onDraft,
|
||||
onSubmit,
|
||||
onAbort,
|
||||
onScroll,
|
||||
onModel,
|
||||
onThinking,
|
||||
isSubmitting = false,
|
||||
isAborting = false,
|
||||
isModelUpdating = false,
|
||||
locked = false,
|
||||
lockReason = "renewing",
|
||||
status,
|
||||
onCommand,
|
||||
}: {
|
||||
runtime: RuntimeView;
|
||||
onDraft: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onAbort: () => void;
|
||||
onScroll: (atBottom: boolean, scrollTop: number) => void;
|
||||
onModel: (model: Model) => Promise<boolean>;
|
||||
onThinking: (level: string) => Promise<boolean>;
|
||||
isSubmitting?: boolean;
|
||||
isAborting?: boolean;
|
||||
isModelUpdating?: boolean;
|
||||
locked?: boolean;
|
||||
lockReason?: "renewing" | "restarting";
|
||||
status?: SessionStatus;
|
||||
onCommand: (command: RpcCommand) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
|
||||
const runtimeIsWorking =
|
||||
runtime.summary.state === "streaming" || runtime.progress.phase === "working";
|
||||
const isWorking =
|
||||
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
|
||||
const presentedStatus = status ?? {
|
||||
label: runtimeStatusLabel(isWorking, runtime.summary.state),
|
||||
detail: isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail,
|
||||
phase: isWorking ? ("working" as const) : runtime.progress.phase,
|
||||
bridge: "unknown",
|
||||
extensions: Object.values(runtime.extensionStatuses),
|
||||
};
|
||||
const visibleStatus = {
|
||||
label: plainStatusText(presentedStatus.label) || "Pi status",
|
||||
detail: plainStatusText(presentedStatus.detail) || "No status details",
|
||||
bridge: plainStatusText(presentedStatus.bridge) || "unknown",
|
||||
extensions: presentedStatus.extensions.map(plainStatusText).filter(Boolean),
|
||||
};
|
||||
const activeTool = runtime.progress.tool
|
||||
? plainStatusText(runtime.progress.tool)
|
||||
: undefined;
|
||||
const inputTokens = runtime.stats.tokens?.input;
|
||||
const outputTokens = runtime.stats.tokens?.output;
|
||||
const cacheReadTokens = runtime.stats.tokens?.cacheRead;
|
||||
const undividedTokens =
|
||||
inputTokens === undefined && outputTokens === undefined
|
||||
? runtime.stats.tokens?.total
|
||||
: undefined;
|
||||
const context = runtime.stats.contextUsage;
|
||||
const contextPercent =
|
||||
context?.percent ??
|
||||
(context?.tokens != null && context.contextWindow
|
||||
? (context.tokens / context.contextWindow) * 100
|
||||
: undefined);
|
||||
let contextDetail: string | undefined;
|
||||
if (context?.tokens != null) {
|
||||
contextDetail = compactCount(context.tokens);
|
||||
if (context.contextWindow !== undefined)
|
||||
contextDetail += ` / ${compactCount(context.contextWindow)}`;
|
||||
if (contextPercent !== undefined)
|
||||
contextDetail += ` · ${Math.round(contextPercent)}%`;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (runtime.scroll.atBottom && ref.current)
|
||||
ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
|
||||
const scrollToLatest = () => {
|
||||
const transcript = ref.current;
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
onScroll(true, transcript.scrollTop);
|
||||
transcript.focus();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="workflow"
|
||||
id="session-workspace"
|
||||
role="tabpanel"
|
||||
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
|
||||
aria-busy={locked}
|
||||
>
|
||||
<div className="workflow-main" inert={locked ? true : undefined}>
|
||||
<div className="transcript-container">
|
||||
<section
|
||||
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
|
||||
aria-label="Conversation"
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
onScroll={(event) => {
|
||||
const node = event.currentTarget;
|
||||
onScroll(
|
||||
node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
|
||||
node.scrollTop,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isWorking && (
|
||||
<div className="agent-working" role="status" aria-live="polite">
|
||||
<span className="working-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong>Pi is working</strong>
|
||||
<span>
|
||||
{isSubmitting ? "Sending your prompt…" : runtime.progress.detail}
|
||||
</span>
|
||||
{runtime.progress.tool && <small>Using {runtime.progress.tool}</small>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{runtime.loading && !runtime.messages.length && (
|
||||
<p className="muted">Loading session…</p>
|
||||
)}
|
||||
{!runtime.loading && !runtime.messages.length && (
|
||||
<p className="muted">No messages yet.</p>
|
||||
)}
|
||||
{runtime.messages.map((message, index) => (
|
||||
<TranscriptMessage key={index} message={message} />
|
||||
))}
|
||||
{runtime.pendingSubmissions.map((pending) => (
|
||||
<article
|
||||
className={`message user pending-message ${pending.phase}`}
|
||||
key={pending.id}
|
||||
>
|
||||
<strong>
|
||||
You <span>{pendingStatus(pending)}</span>
|
||||
</strong>
|
||||
<pre>{pending.text}</pre>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
{!runtime.scroll.atBottom && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-to-latest"
|
||||
aria-label="Scroll to latest message"
|
||||
title="Scroll to latest message"
|
||||
onClick={scrollToLatest}
|
||||
>
|
||||
<span aria-hidden="true">↓</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<aside className="todos-pane" aria-label="Current todos">
|
||||
<div className="todos-heading">
|
||||
<strong>Todos</strong>
|
||||
<span>{tasks.filter((task) => task.status !== "completed").length}</span>
|
||||
</div>
|
||||
{tasks.length ? (
|
||||
<ol className="todo-list">
|
||||
{tasks.map((task) => (
|
||||
<li className={task.status} key={task.id}>
|
||||
<div>
|
||||
<strong>{task.subject}</strong>
|
||||
{task.activeForm && <small>{task.activeForm}</small>}
|
||||
</div>
|
||||
<span>{task.status.replace("_", " ")}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="muted">No active todos yet.</p>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<footer className="workflow-footer" inert={locked ? true : undefined}>
|
||||
{Object.values(runtime.extensionWidgets)
|
||||
.flatMap((widget) => widget.lines)
|
||||
.map((line, index) => (
|
||||
<div className="extension-widget" key={`${line}-${index}`}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<section
|
||||
className="pi-controls"
|
||||
aria-label="Model and thinking controls"
|
||||
aria-busy={isModelUpdating || locked}
|
||||
>
|
||||
<ModelSelector
|
||||
models={runtime.models}
|
||||
current={runtime.state.model}
|
||||
onSelect={onModel}
|
||||
pending={isModelUpdating || locked}
|
||||
/>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={runtime.state.thinkingLevel ?? "medium"}
|
||||
disabled={isModelUpdating || locked}
|
||||
onChange={(event) => void onThinking(event.currentTarget.value)}
|
||||
>
|
||||
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
|
||||
(level) => (
|
||||
<option key={level}>{level}</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
<div
|
||||
className={`session-status-strip ${presentedStatus.phase}`}
|
||||
role="status"
|
||||
aria-label="Session status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="session-status-primary">
|
||||
<span className="work-dot" aria-hidden="true" />
|
||||
<strong>{visibleStatus.label}</strong>
|
||||
<span className="session-status-detail">{visibleStatus.detail}</span>
|
||||
</span>
|
||||
<span className="session-status-meta" aria-label="Status details">
|
||||
{inputTokens !== undefined && (
|
||||
<span className="status-chip" title="Input tokens">
|
||||
<small>In</small>
|
||||
{compactCount(inputTokens)}
|
||||
</span>
|
||||
)}
|
||||
{outputTokens !== undefined && (
|
||||
<span className="status-chip" title="Output tokens">
|
||||
<small>Out</small>
|
||||
{compactCount(outputTokens)}
|
||||
</span>
|
||||
)}
|
||||
{cacheReadTokens !== undefined && (
|
||||
<span className="status-chip" title="Cache-read tokens">
|
||||
<small>Cache read</small>
|
||||
{compactCount(cacheReadTokens)}
|
||||
</span>
|
||||
)}
|
||||
{undividedTokens !== undefined && (
|
||||
<span className="status-chip" title="Total tokens">
|
||||
<small>Tokens</small>
|
||||
{compactCount(undividedTokens)}
|
||||
</span>
|
||||
)}
|
||||
{contextDetail && (
|
||||
<span className="status-chip" title="Current context window usage">
|
||||
<small>Context</small>
|
||||
{contextDetail}
|
||||
</span>
|
||||
)}
|
||||
{runtime.summary.queueCount > 0 && (
|
||||
<span className="status-chip">
|
||||
<small>Queue</small>
|
||||
{runtime.summary.queueCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="status-chip bridge-status">
|
||||
<small>Bridge</small>
|
||||
{visibleStatus.bridge}
|
||||
</span>
|
||||
{activeTool && (
|
||||
<span className="status-chip">
|
||||
<small>Tool</small>
|
||||
{activeTool}
|
||||
</span>
|
||||
)}
|
||||
{visibleStatus.extensions.map((entry, index) => (
|
||||
<span className="status-chip extension-status" key={`${entry}-${index}`}>
|
||||
{entry}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<div className="composer">
|
||||
<CommandPalette
|
||||
draft={runtime.draft}
|
||||
commands={rpcCommandCatalog}
|
||||
onSelect={onCommand}
|
||||
/>
|
||||
<textarea
|
||||
aria-label="Prompt"
|
||||
value={runtime.draft}
|
||||
disabled={isSubmitting || locked}
|
||||
onChange={(event) => onDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Ask Pi…"
|
||||
/>
|
||||
<div className="composer-actions">
|
||||
{runtimeIsWorking && (
|
||||
<button
|
||||
type="button"
|
||||
className="quiet"
|
||||
disabled={isAborting || locked}
|
||||
onClick={onAbort}
|
||||
>
|
||||
{isAborting ? "Aborting…" : "Abort"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
locked ||
|
||||
isSubmitting ||
|
||||
!runtime.summary.agentId ||
|
||||
!runtime.draft.trim()
|
||||
}
|
||||
onClick={onSubmit}
|
||||
>
|
||||
{isSubmitting ? "Sending…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
{locked && (
|
||||
<div className="session-lock" role="status" aria-live="polite">
|
||||
<div className="session-lock-card">
|
||||
<span className="working-bars session-lock-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
{lockReason === "renewing" ? "Renewing session" : "Restarting Pi"}
|
||||
</strong>
|
||||
<p>
|
||||
{lockReason === "renewing"
|
||||
? "Clearing active history and starting a fresh Pi session…"
|
||||
: "Restoring this Pi session and its extensions…"}
|
||||
</p>
|
||||
<small>Input and output are temporarily locked.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DirectoryWorkspace, WorkspaceState } from "../workspace/types";
|
||||
|
||||
type Props = {
|
||||
state: WorkspaceState;
|
||||
onSelect: (path: string) => void;
|
||||
onAdd: () => void;
|
||||
onForget: (directory: DirectoryWorkspace) => void;
|
||||
};
|
||||
export function DirectorySidebar({ state, onSelect, onAdd, onForget }: Props) {
|
||||
return <aside className="directory-sidebar" aria-label="Directories">
|
||||
<div className="sidebar-heading"><strong>Directories</strong><button onClick={onAdd} aria-label="Add directory">+</button></div>
|
||||
<nav>{state.directoryOrder.map((path) => {
|
||||
const directory = state.directoriesByPath[path]; if (!directory) return null;
|
||||
const selected = path === state.selectedDirectoryPath;
|
||||
return <div className={`directory-row ${selected ? "selected" : ""}`} key={path}>
|
||||
<button className="directory-select" aria-current={selected ? "page" : undefined} onClick={() => onSelect(path)} title={path}>
|
||||
<span>{path.split("/").filter(Boolean).slice(-1)[0] || path}</span>
|
||||
<small>{directory.workingCount ? `${directory.workingCount} working` : `${directory.openCount} open`}</small>
|
||||
<span className="directory-badges" aria-label={`${directory.attentionCount} attention, ${directory.errorCount} errors`}>
|
||||
{directory.attentionCount > 0 && <b className="attention">!{directory.attentionCount}</b>}
|
||||
{directory.errorCount > 0 && <b className="error">×{directory.errorCount}</b>}
|
||||
</span>
|
||||
</button>
|
||||
{!directory.isHome && <button className="forget-directory" aria-label={`Forget ${path}`} onClick={() => onForget(directory)}>×</button>}
|
||||
</div>;
|
||||
})}</nav>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Extension } from "../workspace/types";
|
||||
|
||||
function controls(container: HTMLElement | null) {
|
||||
return [
|
||||
...(container?.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
) ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
export function ExtensionDialog({
|
||||
extension,
|
||||
onRespond,
|
||||
}: {
|
||||
extension?: Extension;
|
||||
onRespond: (response: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const respond = useRef(onRespond);
|
||||
const [value, setValue] = useState("");
|
||||
respond.current = onRespond;
|
||||
|
||||
useEffect(() => {
|
||||
if (!extension) return;
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setValue(
|
||||
extension.prefill ?? extension.initialValue ?? extension.text ?? "",
|
||||
);
|
||||
const frame = requestAnimationFrame(() =>
|
||||
controls(ref.current)[0]?.focus(),
|
||||
);
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
respond.current({ cancelled: true });
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls(ref.current);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener("keydown", keydown);
|
||||
opener.current?.focus();
|
||||
};
|
||||
}, [extension]);
|
||||
|
||||
if (!extension) return null;
|
||||
return (
|
||||
<div className="extension-backdrop">
|
||||
<section
|
||||
ref={ref}
|
||||
className="extension"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="extension-title"
|
||||
>
|
||||
<p className="extension-eyebrow">Pi agent request</p>
|
||||
<h2 id="extension-title">Review request</h2>
|
||||
<div className="extension-request">
|
||||
<p className="extension-request-label">Requested action</p>
|
||||
<p className="extension-request-action">
|
||||
{extension.title ?? "Pi needs input"}
|
||||
</p>
|
||||
{extension.message && (
|
||||
<>
|
||||
<p className="extension-request-label">Request details</p>
|
||||
<p className="extension-request-details">{extension.message}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{extension.method === "select" && (
|
||||
<div className="extension-options">
|
||||
{extension.options?.map((option) => (
|
||||
<button key={option} onClick={() => onRespond({ value: option })}>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{extension.method === "confirm" && (
|
||||
<div className="extension-options">
|
||||
<button onClick={() => onRespond({ confirmed: true })}>
|
||||
Allow request
|
||||
</button>
|
||||
<button
|
||||
className="quiet"
|
||||
onClick={() => onRespond({ confirmed: false })}
|
||||
>
|
||||
Decline request
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{extension.method === "input" && (
|
||||
<>
|
||||
<input
|
||||
aria-label="Extension value"
|
||||
value={value}
|
||||
placeholder={extension.placeholder}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
<button onClick={() => onRespond({ value })}>Submit</button>
|
||||
</>
|
||||
)}
|
||||
{extension.method === "editor" && (
|
||||
<>
|
||||
<textarea
|
||||
aria-label="Extension value"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
/>
|
||||
<button onClick={() => onRespond({ value })}>Submit</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="quiet"
|
||||
onClick={() => onRespond({ cancelled: true })}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Model } from "../workspace/types";
|
||||
import { ModelSelector } from "./ModelSelector";
|
||||
|
||||
const models: Model[] = [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4", name: "Claude Sonnet" },
|
||||
{ provider: "openai", id: "organization/gpt-5", name: "GPT 5" },
|
||||
{ provider: "openai", id: "o3" },
|
||||
{ provider: "google", name: "Missing ID" },
|
||||
];
|
||||
|
||||
describe("ModelSelector", () => {
|
||||
it("groups valid models by provider and marks the current model", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ModelSelector
|
||||
models={models}
|
||||
current={{ provider: "anthropic", id: "claude-sonnet-4" }}
|
||||
onSelect={vi.fn().mockResolvedValue(true)}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /anthropic · Claude Sonnet/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("group", { name: "anthropic" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "openai" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("group", { name: "google" })).toBeNull();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("3 models");
|
||||
expect(screen.getByRole("option", { name: /Claude Sonnet/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("searches provider, name, and ID and preserves IDs containing slashes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn().mockResolvedValue(true);
|
||||
render(<ModelSelector models={models} onSelect={onSelect} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Current model/i }));
|
||||
const search = screen.getByRole("searchbox", { name: "Search models" });
|
||||
await user.type(search, "OPENAI");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("2 models");
|
||||
expect(screen.queryByRole("group", { name: "anthropic" })).toBeNull();
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "gpt 5");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("1 model");
|
||||
|
||||
await user.clear(search);
|
||||
await user.type(search, "ORGANIZATION/GPT-5");
|
||||
await user.click(screen.getByRole("option", { name: /GPT 5/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(models[1]);
|
||||
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an empty state and closes on Escape with focus restored", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ModelSelector models={models} onSelect={vi.fn().mockResolvedValue(true)} />,
|
||||
);
|
||||
const trigger = screen.getByRole("button", { name: /Current model/i });
|
||||
await user.click(trigger);
|
||||
const search = screen.getByRole("searchbox", { name: "Search models" });
|
||||
await waitFor(() => expect(search).toHaveFocus());
|
||||
await user.type(search, "missing-model");
|
||||
expect(screen.getByText(/No models match/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent("0 models");
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog", { name: "Choose model" })).toBeNull();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it("keeps the selector open and restores controls when an update fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn().mockResolvedValue(false);
|
||||
render(<ModelSelector models={models} onSelect={onSelect} />);
|
||||
await user.click(screen.getByRole("button", { name: /Current model/i }));
|
||||
await user.click(screen.getByRole("option", { name: /Claude Sonnet/ }));
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(models[0]);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Choose model" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("searchbox", { name: "Search models" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import type { AgentState, Model } from "../workspace/types";
|
||||
|
||||
type SelectableModel = Model & { provider: string; id: string };
|
||||
type ModelGroup = { provider: string; models: SelectableModel[] };
|
||||
|
||||
function isSelectableModel(model: Model): model is SelectableModel {
|
||||
return Boolean(model.provider?.trim() && model.id?.trim());
|
||||
}
|
||||
|
||||
function modelName(model: SelectableModel) {
|
||||
return model.name?.trim() || model.id;
|
||||
}
|
||||
|
||||
function modelMatches(model: SelectableModel, query: string) {
|
||||
if (!query) return true;
|
||||
return [model.provider, model.id, model.name]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.some((value) => value.toLocaleLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function groupModels(models: SelectableModel[], query: string): ModelGroup[] {
|
||||
const byProvider = new Map<string, SelectableModel[]>();
|
||||
for (const model of models) {
|
||||
if (!modelMatches(model, query)) continue;
|
||||
const providerModels = byProvider.get(model.provider) ?? [];
|
||||
providerModels.push(model);
|
||||
byProvider.set(model.provider, providerModels);
|
||||
}
|
||||
return [...byProvider.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([provider, providerModels]) => ({
|
||||
provider,
|
||||
models: providerModels.sort((left, right) =>
|
||||
modelName(left).localeCompare(modelName(right)),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function ProviderGroup({
|
||||
group,
|
||||
current,
|
||||
headingId,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
group: ModelGroup;
|
||||
current?: AgentState["model"];
|
||||
headingId: string;
|
||||
onSelect: (model: SelectableModel) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="model-provider-group"
|
||||
role="group"
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
<div className="model-provider-heading" id={headingId}>
|
||||
{group.provider}
|
||||
</div>
|
||||
{group.models.map((model) => {
|
||||
const name = modelName(model);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="model-option"
|
||||
role="option"
|
||||
aria-selected={
|
||||
model.provider === current?.provider && model.id === current?.id
|
||||
}
|
||||
key={`${model.provider}/${model.id}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelect(model)}
|
||||
>
|
||||
<span>{name}</span>
|
||||
{name !== model.id && <small>{model.id}</small>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSelector({
|
||||
models,
|
||||
current,
|
||||
onSelect,
|
||||
pending = false,
|
||||
}: {
|
||||
models: Model[];
|
||||
current?: AgentState["model"];
|
||||
onSelect: (model: Model) => Promise<boolean>;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const labelId = useId();
|
||||
const valueId = useId();
|
||||
const menuId = useId();
|
||||
const availableModels = useMemo(
|
||||
() => models.filter(isSelectableModel),
|
||||
[models],
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => groupModels(availableModels, query.trim().toLocaleLowerCase()),
|
||||
[availableModels, query],
|
||||
);
|
||||
const resultCount = groups.reduce(
|
||||
(count, group) => count + group.models.length,
|
||||
0,
|
||||
);
|
||||
const selectedModel = availableModels.find(
|
||||
(model) => model.provider === current?.provider && model.id === current?.id,
|
||||
);
|
||||
const selectedName = selectedModel
|
||||
? modelName(selectedModel)
|
||||
: current?.name?.trim() || current?.id;
|
||||
const selectedLabel = current?.provider
|
||||
? `${current.provider} · ${selectedName ?? "Current model"}`
|
||||
: "Current model";
|
||||
const busy = pending || selecting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const focusFrame = window.requestAnimationFrame(() =>
|
||||
searchRef.current?.focus(),
|
||||
);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
};
|
||||
const closeOutside = (event: PointerEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
window.addEventListener("pointerdown", closeOutside);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(focusFrame);
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
window.removeEventListener("pointerdown", closeOutside);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const chooseModel = async (model: SelectableModel) => {
|
||||
if (busy) return;
|
||||
setSelecting(true);
|
||||
try {
|
||||
if (!(await onSelect(model))) return;
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
} finally {
|
||||
setSelecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="model-control" aria-busy={busy}>
|
||||
<span id={labelId}>Model</span>
|
||||
<div className="model-selector" ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="model-trigger"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={open ? menuId : undefined}
|
||||
aria-labelledby={`${labelId} ${valueId}`}
|
||||
disabled={availableModels.length === 0 || busy}
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
<span id={valueId}>{selectedLabel}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={menuId}
|
||||
className="model-menu"
|
||||
role="dialog"
|
||||
aria-label="Choose model"
|
||||
>
|
||||
<label className="sr-only" htmlFor={`${menuId}-search`}>
|
||||
Search models
|
||||
</label>
|
||||
<input
|
||||
ref={searchRef}
|
||||
id={`${menuId}-search`}
|
||||
type="search"
|
||||
value={query}
|
||||
disabled={busy}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder="Search provider, name, or model ID"
|
||||
aria-controls={`${menuId}-results`}
|
||||
/>
|
||||
<p className="model-result-count" role="status" aria-live="polite">
|
||||
{busy
|
||||
? "Updating Pi model…"
|
||||
: `${resultCount} model${resultCount === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
{groups.length ? (
|
||||
<div
|
||||
id={`${menuId}-results`}
|
||||
className="model-results"
|
||||
role="listbox"
|
||||
aria-label="Available models"
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<ProviderGroup
|
||||
key={group.provider}
|
||||
group={group}
|
||||
current={current}
|
||||
headingId={`${menuId}-provider-${index}`}
|
||||
onSelect={(model) => void chooseModel(model)}
|
||||
disabled={busy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="model-empty muted">No models match “{query.trim()}”.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { type RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { DirectorySession } from "../workspace/types";
|
||||
|
||||
const focusableSelector =
|
||||
'button:not(:disabled), input:not(:disabled), [href], [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function sessionTitle(session: DirectorySession) {
|
||||
return session.name ?? session.firstMessage ?? "Untitled session";
|
||||
}
|
||||
|
||||
function messageCountLabel(count: number) {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
function matchesQuery(session: DirectorySession, query: string) {
|
||||
return [session.name, session.firstMessage, session.id].some((value) =>
|
||||
value?.toLocaleLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
onOpen,
|
||||
}: {
|
||||
session: DirectorySession;
|
||||
onOpen: (session: DirectorySession) => void;
|
||||
}) {
|
||||
const showPreview = Boolean(
|
||||
session.name &&
|
||||
session.firstMessage &&
|
||||
session.firstMessage !== session.name,
|
||||
);
|
||||
return (
|
||||
<li>
|
||||
<button className="session-row" onClick={() => onOpen(session)}>
|
||||
<span className="session-row-content">
|
||||
<strong>{sessionTitle(session)}</strong>
|
||||
{showPreview ? (
|
||||
<span className="session-preview">{session.firstMessage}</span>
|
||||
) : null}
|
||||
<small className="session-metadata">
|
||||
<span>{messageCountLabel(session.messageCount)}</span>
|
||||
<time dateTime={session.modified}>
|
||||
Modified {new Date(session.modified).toLocaleDateString()}
|
||||
</time>
|
||||
</small>
|
||||
</span>
|
||||
{session.runtimeId ? <span className="session-open">Open</span> : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionResults({
|
||||
sessions,
|
||||
query,
|
||||
onOpen,
|
||||
}: {
|
||||
sessions: DirectorySession[];
|
||||
query: string;
|
||||
onOpen: (session: DirectorySession) => void;
|
||||
}) {
|
||||
if (sessions.length === 0)
|
||||
return <p className="muted">No saved sessions match “{query}”.</p>;
|
||||
return (
|
||||
<ul id="session-results" className="session-list">
|
||||
{sessions.map((session) => (
|
||||
<SessionRow key={session.path} session={session} onOpen={onOpen} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function useDialogFocus(
|
||||
dialogRef: RefObject<HTMLElement | null>,
|
||||
searchRef: RefObject<HTMLInputElement | null>,
|
||||
onClose: () => void,
|
||||
) {
|
||||
const openerRef = useRef<HTMLElement | null>(
|
||||
document.activeElement as HTMLElement | null,
|
||||
);
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
const controls = () => [
|
||||
...(dialog?.querySelectorAll<HTMLElement>(focusableSelector) ?? []),
|
||||
];
|
||||
searchRef.current?.focus();
|
||||
const keydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = controls();
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", keydown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", keydown);
|
||||
openerRef.current?.focus();
|
||||
};
|
||||
}, [dialogRef, onClose, searchRef]);
|
||||
}
|
||||
|
||||
export function SessionPicker({
|
||||
sessions,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: {
|
||||
sessions: DirectorySession[];
|
||||
onOpen: (session: DirectorySession) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const dialogRef = useRef<HTMLElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase();
|
||||
const filteredSessions = useMemo(
|
||||
() => sessions.filter((session) => matchesQuery(session, normalizedQuery)),
|
||||
[normalizedQuery, sessions],
|
||||
);
|
||||
useDialogFocus(dialogRef, searchRef, onClose);
|
||||
const resultLabel = `${filteredSessions.length} saved session${filteredSessions.length === 1 ? "" : "s"}`;
|
||||
return (
|
||||
<div className="modal-backdrop">
|
||||
<section
|
||||
ref={dialogRef}
|
||||
className="session-picker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="session-picker-title"
|
||||
>
|
||||
<div className="picker-heading">
|
||||
<h2 id="session-picker-title">Saved sessions</h2>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<div className="session-search">
|
||||
<label className="sr-only" htmlFor="session-search">
|
||||
Search saved sessions
|
||||
</label>
|
||||
<input
|
||||
ref={searchRef}
|
||||
id="session-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search by name, message, or ID"
|
||||
aria-controls="session-results"
|
||||
/>
|
||||
</div>
|
||||
<p className="session-result-count" role="status" aria-live="polite">
|
||||
{sessions.length ? resultLabel : "No saved sessions"}
|
||||
</p>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="muted">No saved sessions for this directory.</p>
|
||||
) : (
|
||||
<SessionResults
|
||||
sessions={filteredSessions}
|
||||
query={query}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useRef, type KeyboardEvent } from "react";
|
||||
import type { RuntimeView } from "../workspace/types";
|
||||
|
||||
type Props = {
|
||||
runtimes: RuntimeView[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string) => void;
|
||||
onClose: (runtime: RuntimeView) => void;
|
||||
onRenew: (runtime: RuntimeView) => void;
|
||||
onNew: () => void;
|
||||
onResume: () => void;
|
||||
busy?: boolean;
|
||||
closingRuntimeIds?: ReadonlySet<string>;
|
||||
renewingRuntimeIds?: ReadonlySet<string>;
|
||||
restoreFocus?: boolean;
|
||||
onFocusRestored?: () => void;
|
||||
};
|
||||
export function SessionTabs({
|
||||
runtimes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onRenew,
|
||||
onNew,
|
||||
onResume,
|
||||
busy,
|
||||
closingRuntimeIds = new Set(),
|
||||
renewingRuntimeIds = new Set(),
|
||||
restoreFocus,
|
||||
onFocusRestored,
|
||||
}: Props) {
|
||||
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const newSessionRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!restoreFocus) return;
|
||||
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
|
||||
onFocusRestored?.();
|
||||
}, [restoreFocus, selectedId, onFocusRestored]);
|
||||
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
|
||||
let next: number | undefined;
|
||||
if (!runtimes.length) return;
|
||||
if (event.key === "ArrowRight") next = (index + 1) % runtimes.length;
|
||||
else if (event.key === "ArrowLeft")
|
||||
next = (index - 1 + runtimes.length) % runtimes.length;
|
||||
else if (event.key === "Home") next = 0;
|
||||
else if (event.key === "End") next = runtimes.length - 1;
|
||||
if (next === undefined) return;
|
||||
event.preventDefault();
|
||||
refs.current[runtimes[next].summary.runtimeId]?.focus();
|
||||
onSelect(runtimes[next].summary.runtimeId);
|
||||
}
|
||||
return (
|
||||
<div className="session-strip">
|
||||
<div className="session-tab-bar" aria-label="Sessions">
|
||||
<div className="session-tabs" role="tablist" aria-label="Open sessions">
|
||||
{runtimes.map((runtime, index) => {
|
||||
const summary = runtime.summary;
|
||||
const selected = summary.runtimeId === selectedId;
|
||||
const renewing = renewingRuntimeIds.has(summary.runtimeId);
|
||||
const closing = closingRuntimeIds.has(summary.runtimeId);
|
||||
return (
|
||||
<div
|
||||
className={`session-tab ${selected ? "selected" : ""}`}
|
||||
key={summary.runtimeId}
|
||||
>
|
||||
<button
|
||||
role="tab"
|
||||
id={`session-tab-${summary.runtimeId}`}
|
||||
aria-controls="session-workspace"
|
||||
aria-selected={selected}
|
||||
tabIndex={selected || (!selectedId && index === 0) ? 0 : -1}
|
||||
ref={(element) => {
|
||||
refs.current[summary.runtimeId] = element;
|
||||
}}
|
||||
onKeyDown={(event) => keydown(event, index)}
|
||||
onClick={() => onSelect(summary.runtimeId)}
|
||||
>
|
||||
<span>{summary.label}</span>
|
||||
<small className={summary.state}>
|
||||
{summary.state}
|
||||
{summary.queueCount ? ` · ${summary.queueCount} queued` : ""}
|
||||
</small>
|
||||
{runtime.unread && (
|
||||
<i className="unread-dot" aria-label="Unread activity" />
|
||||
)}
|
||||
{summary.attention && (
|
||||
<b className="attention-dot" aria-label="Needs attention">
|
||||
!
|
||||
</b>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="renew-session"
|
||||
aria-label={
|
||||
renewing ? `Renewing ${summary.label}` : `Renew ${summary.label}`
|
||||
}
|
||||
aria-busy={renewing}
|
||||
title={`Renew ${summary.label} and clear active history`}
|
||||
disabled={renewing || closing}
|
||||
onClick={() => onRenew(runtime)}
|
||||
>
|
||||
{renewing ? (
|
||||
<span className="tab-action-spinner" aria-hidden="true" />
|
||||
) : (
|
||||
<svg
|
||||
className="renew-session-icon"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 11a8 8 0 1 1-2.34-5.66M20 4v7h-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="close-session"
|
||||
aria-label={
|
||||
closing ? `Closing ${summary.label}` : `Close ${summary.label}`
|
||||
}
|
||||
aria-busy={closing}
|
||||
disabled={renewing || closing}
|
||||
onClick={() => onClose(runtime)}
|
||||
>
|
||||
{closing ? "…" : "×"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="new-session-tab"
|
||||
ref={newSessionRef}
|
||||
aria-label={busy ? "Starting new session" : "New session"}
|
||||
title="New session"
|
||||
disabled={busy}
|
||||
onClick={onNew}
|
||||
>
|
||||
<span aria-hidden="true">{busy ? "…" : "+"}</span>
|
||||
</button>
|
||||
<div className="session-tab-actions" aria-label="Session actions">
|
||||
<button className="load-session" disabled={busy} onClick={onResume}>
|
||||
<span aria-hidden="true">⌄</span>
|
||||
{busy ? "Working…" : "Load"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DirectorySidebar } from "./DirectorySidebar";
|
||||
import { SessionTabs } from "./SessionTabs";
|
||||
import { SessionPicker } from "./SessionPicker";
|
||||
import { ConversationWorkspace } from "./ConversationWorkspace";
|
||||
import { ExtensionDialog } from "./ExtensionDialog";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
runtimeView,
|
||||
} from "../workspace/reducer";
|
||||
import type { DirectorySession, Workspace } from "../workspace/types";
|
||||
const summary = (id: string, state = "idle") => ({
|
||||
runtimeId: id,
|
||||
worktreePath: "/repo",
|
||||
state,
|
||||
label: id,
|
||||
attention: id === "b",
|
||||
queueCount: id === "b" ? 2 : 0,
|
||||
lastActivity: id,
|
||||
openedAt: id,
|
||||
});
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "e",
|
||||
latestSeq: 0,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: false,
|
||||
openCount: 2,
|
||||
workingCount: 1,
|
||||
attentionCount: 1,
|
||||
recoveringCount: 0,
|
||||
errorCount: 1,
|
||||
runtimes: [summary("a"), summary("b", "streaming")],
|
||||
},
|
||||
],
|
||||
};
|
||||
describe("multi-session navigation", () => {
|
||||
it("renders directory aggregates and accessible keyboard session tabs", async () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const onSelect = vi.fn();
|
||||
const onNew = vi.fn();
|
||||
const onRenew = vi.fn();
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<DirectorySidebar
|
||||
state={state}
|
||||
onSelect={vi.fn()}
|
||||
onAdd={vi.fn()}
|
||||
onForget={vi.fn()}
|
||||
/>
|
||||
<SessionTabs
|
||||
runtimes={[
|
||||
runtimeView(summary("a")),
|
||||
runtimeView(summary("b", "streaming")),
|
||||
]}
|
||||
selectedId="a"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onRenew={onRenew}
|
||||
onNew={onNew}
|
||||
onResume={vi.fn()}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
|
||||
const closeFirst = screen.getByRole("button", { name: "Close a" });
|
||||
const renewFirst = screen.getByRole("button", { name: "Renew a" });
|
||||
expect(tabs[0].parentElement?.lastElementChild).toBe(closeFirst);
|
||||
expect(closeFirst.previousElementSibling).toBe(renewFirst);
|
||||
const tablist = screen.getByRole("tablist", { name: "Open sessions" });
|
||||
const newSession = screen.getByRole("button", { name: "New session" });
|
||||
expect(tablist.nextElementSibling).toBe(newSession);
|
||||
await userEvent.click(newSession);
|
||||
expect(onNew).toHaveBeenCalledOnce();
|
||||
await userEvent.click(tabs[0]);
|
||||
await userEvent.keyboard("{ArrowRight}");
|
||||
expect(onSelect).toHaveBeenCalledWith("b");
|
||||
rerender(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("b", "streaming"))]}
|
||||
selectedId="b"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
|
||||
});
|
||||
it("announces close progress only on the closing session", () => {
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
closingRuntimeIds={new Set(["b"])}
|
||||
/>,
|
||||
);
|
||||
const closing = screen.getByRole("button", { name: "Closing b" });
|
||||
expect(closing).toBeDisabled();
|
||||
expect(closing).toHaveAttribute("aria-busy", "true");
|
||||
expect(screen.getByRole("button", { name: "Close a" })).toBeEnabled();
|
||||
});
|
||||
it("restores focus to the selected adjacent tab after close", () => {
|
||||
const onFocusRestored = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
|
||||
selectedId="b"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRenew={vi.fn()}
|
||||
onNew={vi.fn()}
|
||||
onResume={vi.fn()}
|
||||
restoreFocus
|
||||
onFocusRestored={onFocusRestored}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
|
||||
expect(onFocusRestored).toHaveBeenCalled();
|
||||
});
|
||||
it("shows two-line tool results and previews longer collapsed results", async () => {
|
||||
const user = userEvent.setup();
|
||||
const runtime = runtimeView(summary("tool-result"));
|
||||
runtime.messages = [
|
||||
{ role: "toolResult", toolName: "read", content: "first\nsecond\n" },
|
||||
];
|
||||
const props = {
|
||||
onDraft: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onAbort: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
onModel: vi.fn().mockResolvedValue(true),
|
||||
onThinking: vi.fn().mockResolvedValue(true),
|
||||
onCommand: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<ConversationWorkspace runtime={runtime} {...props} />,
|
||||
);
|
||||
expect(container.querySelector("details")).toBeNull();
|
||||
expect(container.querySelector(".tool-result-visible pre")).toHaveTextContent(
|
||||
"first second",
|
||||
);
|
||||
|
||||
const longRuntime = {
|
||||
...runtime,
|
||||
messages: [
|
||||
{
|
||||
role: "toolResult",
|
||||
toolName: "read",
|
||||
content: "first\nsecond\nthird\n",
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(<ConversationWorkspace runtime={longRuntime} {...props} />);
|
||||
const details = container.querySelector("details");
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(container.querySelector(".tool-result-preview")?.textContent).toBe(
|
||||
"first\nsecond",
|
||||
);
|
||||
expect(container.querySelector(".tool-result-full")?.textContent).toBe(
|
||||
"first\nsecond\nthird\n",
|
||||
);
|
||||
expect(
|
||||
screen.getByText("1 more line hidden · Expand to view"),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByText("Tool result · read"));
|
||||
expect(details).toHaveAttribute("open");
|
||||
});
|
||||
it("packs session status between model controls and the composer", () => {
|
||||
const runtime = runtimeView(summary("status", "streaming"));
|
||||
runtime.summary.queueCount = 2;
|
||||
runtime.stats = {
|
||||
tokens: { input: 10_000, output: 2_450, cacheRead: 8_000, total: 12_450 },
|
||||
contextUsage: { tokens: 32_000, contextWindow: 200_000, percent: 16 },
|
||||
};
|
||||
const { container } = render(
|
||||
<ConversationWorkspace
|
||||
runtime={runtime}
|
||||
onDraft={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
onScroll={vi.fn()}
|
||||
onModel={vi.fn()}
|
||||
onThinking={vi.fn()}
|
||||
onCommand={vi.fn()}
|
||||
status={{
|
||||
label: "Working",
|
||||
detail: "Thinking and preparing a response",
|
||||
phase: "working",
|
||||
bridge: "ready",
|
||||
extensions: ["\u001B[32mLSP\u001B[0m \uE0B0\uFFFDactive\u0007"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const modelControls = screen.getByRole("region", {
|
||||
name: "Model and thinking controls",
|
||||
});
|
||||
const status = screen.getByRole("status", { name: "Session status" });
|
||||
const composer = container.querySelector(".composer");
|
||||
|
||||
for (const text of [
|
||||
"Working",
|
||||
"Thinking and preparing a response",
|
||||
"Bridge",
|
||||
"ready",
|
||||
"In",
|
||||
"10k",
|
||||
"Out",
|
||||
"2.5k",
|
||||
"Cache read",
|
||||
"8k",
|
||||
"Context",
|
||||
"32k / 200k · 16%",
|
||||
"Queue",
|
||||
"2",
|
||||
"LSP active",
|
||||
])
|
||||
expect(status).toHaveTextContent(text);
|
||||
expect(status.textContent).not.toMatch(/[\u001B\u0007\uE0B0\uFFFD]/u);
|
||||
expect(modelControls.nextElementSibling).toBe(status);
|
||||
expect(status.nextElementSibling).toBe(composer);
|
||||
});
|
||||
it("shows a jump control only while scrolled up and moves to the latest message", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onScroll = vi.fn();
|
||||
const atBottom = runtimeView(summary("scroll"));
|
||||
const props = {
|
||||
onDraft: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
onAbort: vi.fn(),
|
||||
onScroll,
|
||||
onModel: vi.fn().mockResolvedValue(true),
|
||||
onThinking: vi.fn().mockResolvedValue(true),
|
||||
onCommand: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(
|
||||
<ConversationWorkspace runtime={atBottom} {...props} />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Scroll to latest message" }),
|
||||
).toBeNull();
|
||||
|
||||
const scrolledUp = {
|
||||
...atBottom,
|
||||
scroll: { atBottom: false, scrollTop: 125 },
|
||||
};
|
||||
rerender(<ConversationWorkspace runtime={scrolledUp} {...props} />);
|
||||
const transcript = screen.getByRole("region", { name: "Conversation" });
|
||||
Object.defineProperty(transcript, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 600,
|
||||
});
|
||||
transcript.scrollTop = 125;
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Scroll to latest message" }),
|
||||
);
|
||||
|
||||
expect(transcript.scrollTop).toBe(600);
|
||||
expect(onScroll).toHaveBeenCalledWith(true, 600);
|
||||
expect(transcript).toHaveFocus();
|
||||
});
|
||||
it("shows an aborting control and labels an accepted follow-up", () => {
|
||||
const runtime = runtimeView(summary("working", "streaming"));
|
||||
runtime.progress = {
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
toolCount: 0,
|
||||
};
|
||||
runtime.pendingSubmissions = [
|
||||
{
|
||||
id: 1,
|
||||
text: "Continue with the plan",
|
||||
userMessageCount: 0,
|
||||
phase: "sent",
|
||||
delivery: "follow_up",
|
||||
},
|
||||
];
|
||||
render(
|
||||
<ConversationWorkspace
|
||||
runtime={runtime}
|
||||
onDraft={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onAbort={vi.fn()}
|
||||
onScroll={vi.fn()}
|
||||
onModel={vi.fn()}
|
||||
onThinking={vi.fn()}
|
||||
onCommand={vi.fn()}
|
||||
isAborting
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Aborting…" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("Follow-up queued · waiting for Pi"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
it("searches saved sessions and displays useful metadata", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpen = vi.fn();
|
||||
const sessions: DirectorySession[] = [
|
||||
{
|
||||
path: "/planning",
|
||||
id: "plan-id",
|
||||
cwd: "/repo",
|
||||
name: "Planning",
|
||||
firstMessage: "Sketch the release plan",
|
||||
modified: "2026-08-15T10:00:00.000Z",
|
||||
messageCount: 1,
|
||||
runtimeId: "already-open",
|
||||
},
|
||||
{
|
||||
path: "/incident",
|
||||
id: "incident-42",
|
||||
cwd: "/repo",
|
||||
firstMessage: "Investigate search crash",
|
||||
modified: "2026-08-14T10:00:00.000Z",
|
||||
messageCount: 2,
|
||||
},
|
||||
];
|
||||
render(
|
||||
<SessionPicker sessions={sessions} onOpen={onOpen} onClose={vi.fn()} />,
|
||||
);
|
||||
const search = screen.getByRole("searchbox", {
|
||||
name: "Search saved sessions",
|
||||
});
|
||||
expect(screen.getByRole("status")).toHaveTextContent("2 saved sessions");
|
||||
expect(screen.getByText("Sketch the release plan")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 message")).toBeInTheDocument();
|
||||
expect(screen.getByText("Open")).toBeInTheDocument();
|
||||
|
||||
await user.type(search, "release");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("1 saved session");
|
||||
expect(screen.getByRole("button", { name: /Planning/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Investigate/ })).toBeNull();
|
||||
await user.clear(search);
|
||||
await user.type(search, "INCIDENT-42");
|
||||
await user.click(screen.getByRole("button", { name: /Investigate/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith(sessions[1]);
|
||||
await user.clear(search);
|
||||
await user.type(search, "missing");
|
||||
expect(screen.getByText(/No saved sessions match/)).toBeInTheDocument();
|
||||
});
|
||||
it("focuses search, traps focus, and restores the opener", async () => {
|
||||
const user = userEvent.setup();
|
||||
const opener = document.createElement("button");
|
||||
document.body.append(opener);
|
||||
opener.focus();
|
||||
const onClose = vi.fn();
|
||||
const { unmount } = render(
|
||||
<SessionPicker sessions={[]} onOpen={vi.fn()} onClose={onClose} />,
|
||||
);
|
||||
const search = screen.getByRole("searchbox", {
|
||||
name: "Search saved sessions",
|
||||
});
|
||||
const close = screen.getByRole("button", { name: "Close" });
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(close).toHaveFocus();
|
||||
await user.keyboard("{Shift>}{Tab}{/Shift}");
|
||||
expect(search).toHaveFocus();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(opener).toHaveFocus();
|
||||
document.body.removeChild(opener);
|
||||
});
|
||||
it("labels permission requests with the action and exact details", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRespond = vi.fn();
|
||||
render(
|
||||
<ExtensionDialog
|
||||
extension={{
|
||||
method: "confirm",
|
||||
title: "Run a shell command",
|
||||
message: "rm -rf ./build\nThis removes the generated build directory.",
|
||||
}}
|
||||
onRespond={onRespond}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Pi agent request")).toBeInTheDocument();
|
||||
expect(screen.getByText("Requested action")).toBeInTheDocument();
|
||||
expect(screen.getByText("Run a shell command")).toBeInTheDocument();
|
||||
expect(screen.getByText("Request details")).toBeInTheDocument();
|
||||
expect(screen.getByText(/rm -rf .\/build/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Allow request" }));
|
||||
expect(onRespond).toHaveBeenCalledWith({ confirmed: true });
|
||||
});
|
||||
it("uses Pi editor prefill content", () => {
|
||||
render(
|
||||
<ExtensionDialog
|
||||
extension={{
|
||||
method: "editor",
|
||||
title: "Edit request",
|
||||
prefill: "Existing request",
|
||||
}}
|
||||
onRespond={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Extension value")).toHaveValue(
|
||||
"Existing request",
|
||||
);
|
||||
});
|
||||
it("routes new, saved-open, renew, and close controls through callbacks", async () => {
|
||||
const onNew = vi.fn(),
|
||||
onResume = vi.fn(),
|
||||
onRenew = vi.fn(),
|
||||
onClose = vi.fn();
|
||||
render(
|
||||
<SessionTabs
|
||||
runtimes={[runtimeView(summary("a"))]}
|
||||
selectedId="a"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onRenew={onRenew}
|
||||
onNew={onNew}
|
||||
onResume={onResume}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Load" }));
|
||||
await userEvent.click(screen.getByLabelText("Renew a"));
|
||||
await userEvent.click(screen.getByLabelText("Close a"));
|
||||
expect(onNew).toHaveBeenCalled();
|
||||
expect(onResume).toHaveBeenCalled();
|
||||
expect(onRenew).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
summary: expect.objectContaining({ runtimeId: "a" }),
|
||||
}),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import App from "./App";
|
||||
import QuickPane from "./quick-pane/QuickPane";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
{getCurrentWindow().label === "quick-pane" ? <QuickPane /> : <App />}
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ExtensionDialog } from "../components/ExtensionDialog";
|
||||
import "./quick-pane.css";
|
||||
import type {
|
||||
Extension,
|
||||
Model,
|
||||
QuickPaneSettings,
|
||||
RuntimeSnapshot,
|
||||
UiState,
|
||||
} from "../workspace/types";
|
||||
|
||||
const defaults: QuickPaneSettings = {
|
||||
workspace: "",
|
||||
provider: "",
|
||||
model: "",
|
||||
followUpProvider: "",
|
||||
followUpModel: "",
|
||||
instructions:
|
||||
"Answer quickly. Do not use subagents. Prefer read-only inspection. Escalate mutations, multi-step work, or work likely to exceed budget.",
|
||||
maximumSeconds: 60,
|
||||
maximumToolCalls: 6,
|
||||
};
|
||||
|
||||
type Runtime = { agentId: string; runtimeId: string };
|
||||
|
||||
function settingsError(settings: QuickPaneSettings) {
|
||||
if (!settings.workspace.startsWith("/"))
|
||||
return "Workspace must be an absolute path.";
|
||||
if (!settings.provider || !settings.model)
|
||||
return "Quick provider and model are required.";
|
||||
if (!settings.followUpProvider || !settings.followUpModel)
|
||||
return "Follow-up provider and model are required.";
|
||||
if (!settings.instructions.trim())
|
||||
return "Supplemental instructions are required.";
|
||||
if (
|
||||
!Number.isInteger(settings.maximumSeconds) ||
|
||||
settings.maximumSeconds < 1 ||
|
||||
settings.maximumSeconds > 600
|
||||
)
|
||||
return "Seconds must be an integer from 1 to 600.";
|
||||
if (
|
||||
!Number.isInteger(settings.maximumToolCalls) ||
|
||||
settings.maximumToolCalls < 1 ||
|
||||
settings.maximumToolCalls > 64
|
||||
)
|
||||
return "Tool calls must be an integer from 1 to 64.";
|
||||
}
|
||||
|
||||
function assistantText(snapshot: RuntimeSnapshot) {
|
||||
const messages = snapshot.transcript?.data?.messages ?? [];
|
||||
const message = [...messages]
|
||||
.reverse()
|
||||
.find((candidate) => candidate.role === "assistant");
|
||||
if (!message) return "";
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return (message.content ?? [])
|
||||
.map((part) => part.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export default function QuickPane() {
|
||||
const [settings, setSettings] = useState<QuickPaneSettings>(defaults);
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||
const [request, setRequest] = useState("");
|
||||
const [thinkingLevel, setThinkingLevel] = useState("medium");
|
||||
const [models, setModels] = useState<Model[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [status, setStatus] = useState("Loading Quick Pane settings…");
|
||||
const [error, setError] = useState<string>();
|
||||
const [runtime, setRuntime] = useState<Runtime>();
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [escalation, setEscalation] = useState<string>();
|
||||
const [extension, setExtension] = useState<Extension>();
|
||||
const runtimeRef = useRef<Runtime | undefined>(undefined);
|
||||
const requestRef = useRef(request);
|
||||
const phaseRef = useRef<"idle" | "starting" | "running">("idle");
|
||||
const invalid = useMemo(() => settingsError(settings), [settings]);
|
||||
|
||||
const dismissPane = () => getCurrentWindow().hide();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
invoke<UiState>("load_ui_state")
|
||||
.then((state) => {
|
||||
if (!active) return;
|
||||
const nextSettings = { ...defaults, ...state.quickPane };
|
||||
setSettings(nextSettings);
|
||||
setSettingsLoaded(true);
|
||||
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
||||
setStatus("");
|
||||
return invoke<{ models?: Model[] }>("get_model_catalog");
|
||||
})
|
||||
.then((catalog) => {
|
||||
if (active) setModels(catalog?.models ?? []);
|
||||
})
|
||||
.catch(
|
||||
(reason) =>
|
||||
active && setError(`Could not load settings: ${String(reason)}`),
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
const current = runtimeRef.current;
|
||||
if (current)
|
||||
invoke("close_quick_runtime", { runtimeId: current.runtimeId }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const paneWindow = getCurrentWindow();
|
||||
const settingsListener = listen<QuickPaneSettings>(
|
||||
"quick-pane-settings-saved",
|
||||
(event) => {
|
||||
const nextSettings = { ...defaults, ...event.payload };
|
||||
setSettings(nextSettings);
|
||||
setSelectedModel(`${nextSettings.provider}/${nextSettings.model}`);
|
||||
},
|
||||
);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") void dismissPane();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
const focusListener = paneWindow.onFocusChanged((event) => {
|
||||
if (
|
||||
!event.payload &&
|
||||
phaseRef.current === "idle" &&
|
||||
!requestRef.current.trim()
|
||||
)
|
||||
void dismissPane();
|
||||
});
|
||||
const closeListener = paneWindow.onCloseRequested((event) => {
|
||||
event.preventDefault();
|
||||
void dismissPane();
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
void settingsListener.then((unlisten) => unlisten());
|
||||
void focusListener.then((unlisten) => unlisten());
|
||||
void closeListener.then((unlisten) => unlisten());
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime) return;
|
||||
let active = true;
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const snapshot = await invoke<RuntimeSnapshot>(
|
||||
"get_session_runtime_snapshot",
|
||||
{
|
||||
runtimeId: runtime.runtimeId,
|
||||
},
|
||||
);
|
||||
if (!active) return;
|
||||
const text = assistantText(snapshot);
|
||||
if (text) {
|
||||
setAnswer(text);
|
||||
if (text.includes("[ESCALATE]")) {
|
||||
setEscalation(
|
||||
text.replace("[ESCALATE]", "").trim() ||
|
||||
"Quick agent requested a full session.",
|
||||
);
|
||||
} else if (!snapshot.state?.data?.isStreaming) {
|
||||
setStatus("Quick answer ready.");
|
||||
}
|
||||
}
|
||||
setExtension(snapshot.extensions[0]);
|
||||
if (snapshot.runtime.error?.message) {
|
||||
setError(snapshot.runtime.error.message);
|
||||
setEscalation(
|
||||
"Quick runtime failed. Continue in a full session if needed.",
|
||||
);
|
||||
}
|
||||
} catch (reason) {
|
||||
if (active)
|
||||
setError(`Could not refresh quick result: ${String(reason)}`);
|
||||
}
|
||||
};
|
||||
void refresh();
|
||||
const interval = window.setInterval(() => void refresh(), 800);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [runtime]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runtime) return;
|
||||
const timeout = window.setTimeout(() => {
|
||||
setEscalation(
|
||||
`Quick time budget of ${settings.maximumSeconds} seconds reached.`,
|
||||
);
|
||||
setStatus(
|
||||
"Quick time budget reached. Showing partial result if available.",
|
||||
);
|
||||
invoke("abort", { agentId: runtime.agentId }).catch(() => undefined);
|
||||
}, settings.maximumSeconds * 1000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [runtime, settings.maximumSeconds]);
|
||||
|
||||
async function run() {
|
||||
if (invalid || !request.trim())
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
const previous = runtimeRef.current;
|
||||
if (previous)
|
||||
await invoke("close_quick_runtime", {
|
||||
runtimeId: previous.runtimeId,
|
||||
}).catch(() => undefined);
|
||||
setError(undefined);
|
||||
setAnswer("");
|
||||
setEscalation(undefined);
|
||||
setExtension(undefined);
|
||||
phaseRef.current = "starting";
|
||||
setStatus("Starting advisory quick agent…");
|
||||
try {
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
"create_quick_runtime",
|
||||
{
|
||||
worktreePath: settings.workspace,
|
||||
},
|
||||
);
|
||||
runtimeRef.current = result.runtime;
|
||||
setRuntime(result.runtime);
|
||||
const [provider, modelId] = selectedModel.split("/", 2);
|
||||
await invoke("set_model", {
|
||||
agentId: result.runtime.agentId,
|
||||
provider: provider || settings.provider,
|
||||
modelId: modelId || settings.model,
|
||||
});
|
||||
await invoke("set_thinking_level", {
|
||||
agentId: result.runtime.agentId,
|
||||
level: thinkingLevel,
|
||||
});
|
||||
const guardrails = `[Quick Pane advisory mode: target ${settings.maximumSeconds}s and ${settings.maximumToolCalls} read-only tool calls. Do not use subagents. Do not mutate files or system state. If request needs mutation, multi-step work, subagents, or more budget, start final response with [ESCALATE] and explain why.]\n${settings.instructions}\n\nUser request: ${request}`;
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
message: guardrails,
|
||||
});
|
||||
phaseRef.current = "running";
|
||||
setStatus(
|
||||
"Quick request running. Advisory limits are not trusted enforcement.",
|
||||
);
|
||||
} catch (reason) {
|
||||
phaseRef.current = "idle";
|
||||
setError(`Could not start quick request: ${String(reason)}`);
|
||||
setStatus("");
|
||||
}
|
||||
}
|
||||
|
||||
async function respond(response: Record<string, unknown>) {
|
||||
if (!runtime || !extension?.id) return;
|
||||
try {
|
||||
await invoke("respond_to_extension", {
|
||||
agentId: runtime.agentId,
|
||||
requestId: extension.id,
|
||||
response,
|
||||
});
|
||||
setExtension(undefined);
|
||||
} catch (reason) {
|
||||
setError(`Could not answer Pi request: ${String(reason)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function continueFull() {
|
||||
if (invalid || !request.trim())
|
||||
return setError(invalid ?? "Enter a request.");
|
||||
try {
|
||||
const result = await invoke<{ runtime: Runtime }>(
|
||||
"create_session_runtime",
|
||||
{
|
||||
worktreePath: settings.workspace,
|
||||
},
|
||||
);
|
||||
await invoke("set_model", {
|
||||
agentId: result.runtime.agentId,
|
||||
provider: settings.followUpProvider,
|
||||
modelId: settings.followUpModel,
|
||||
});
|
||||
await invoke("submit_prompt", {
|
||||
agentId: result.runtime.agentId,
|
||||
message: `${request}\n\nQuick Pane escalation: ${escalation ?? "Continue this in a full session."}\n\nPartial quick result:\n${answer}`,
|
||||
});
|
||||
setStatus("Full session started with follow-up model.");
|
||||
} catch (reason) {
|
||||
setError(`Could not start full session: ${String(reason)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="quick-pane">
|
||||
<header style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<strong>Quick Pane</strong>
|
||||
<button onClick={dismissPane}>Close</button>
|
||||
</header>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={request}
|
||||
onChange={(e) => {
|
||||
requestRef.current = e.target.value;
|
||||
setRequest(e.target.value);
|
||||
}}
|
||||
placeholder="Ask a quick question or give a quick instruction"
|
||||
style={{ width: "100%", minHeight: 90, marginTop: 14 }}
|
||||
/>
|
||||
<label>
|
||||
Model
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(event) => setSelectedModel(event.currentTarget.value)}
|
||||
>
|
||||
<option value={`${settings.provider}/${settings.model}`}>
|
||||
{settings.provider}/{settings.model}
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option
|
||||
key={`${model.provider}/${model.id}`}
|
||||
value={`${model.provider}/${model.id}`}
|
||||
>
|
||||
{model.provider}/{model.id ?? model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Thinking
|
||||
<select
|
||||
value={thinkingLevel}
|
||||
onChange={(event) => setThinkingLevel(event.currentTarget.value)}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
disabled={!settingsLoaded || !!invalid || !request.trim()}
|
||||
onClick={run}
|
||||
>
|
||||
Ask
|
||||
</button>
|
||||
{(escalation || error) && (
|
||||
<button onClick={continueFull}>Continue in full session</button>
|
||||
)}
|
||||
{status && <p>{status}</p>}
|
||||
{answer && <pre style={{ whiteSpace: "pre-wrap" }}>{answer}</pre>}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<ExtensionDialog extension={extension} onRespond={respond} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; background: transparent; }
|
||||
.quick-pane {
|
||||
box-sizing: border-box;
|
||||
min-height: 100vh;
|
||||
padding: 18px;
|
||||
color: #f4f2ee;
|
||||
font: 14px/1.4 system-ui, sans-serif;
|
||||
background: radial-gradient(circle at top, #46494f, #23252a 62%);
|
||||
border: 1px solid rgba(255,255,255,.16);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 22px 70px rgba(0,0,0,.55);
|
||||
}
|
||||
.quick-pane header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; color:#c9c7c2; font-size:12px; letter-spacing:.08em; text-transform:uppercase; }
|
||||
.quick-pane button, .quick-pane select { border:0; border-radius:7px; padding:7px 10px; color:inherit; background:#45484e; }
|
||||
.quick-pane textarea { box-sizing:border-box; width:100%; min-height:92px; padding:14px; border:0; border-radius:10px; outline:1px solid rgba(255,255,255,.14); color:#fff; background:rgba(0,0,0,.24); font:inherit; font-size:16px; resize:none; }
|
||||
.quick-pane textarea:focus { outline:2px solid #8aa9d6; background:rgba(0,0,0,.38); }
|
||||
.quick-pane > label { display:inline-flex; align-items:center; gap:7px; margin:10px 8px 10px 0; color:#c9c7c2; }
|
||||
.quick-pane > pre, .quick-pane > p { margin:10px 0 0; padding:10px 12px; border-radius:9px; white-space:pre-wrap; background:rgba(0,0,0,.22); }
|
||||
.quick-pane [role="alert"] { color:#ffb8bd; background:rgba(119,42,52,.42); }
|
||||
@@ -0,0 +1,4 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach } from "vitest";
|
||||
afterEach(cleanup);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
legacyPreferences,
|
||||
removeLegacyPreferences,
|
||||
presentationState,
|
||||
} from "./persistence";
|
||||
import {
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
workspaceReducer,
|
||||
} from "./reducer";
|
||||
import type { Workspace } from "./types";
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "e",
|
||||
latestSeq: 7,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: true,
|
||||
openCount: 1,
|
||||
workingCount: 0,
|
||||
attentionCount: 0,
|
||||
recoveringCount: 0,
|
||||
errorCount: 0,
|
||||
runtimes: [
|
||||
{
|
||||
runtimeId: "r",
|
||||
worktreePath: "/repo",
|
||||
state: "idle",
|
||||
label: "r",
|
||||
attention: false,
|
||||
queueCount: 0,
|
||||
lastActivity: "now",
|
||||
openedAt: "now",
|
||||
agentId: "ephemeral",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
describe("presentation persistence", () => {
|
||||
it("persists only allowlisted presentation state, never agent or session payloads", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "r",
|
||||
draft: "safe draft",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "setDefaultNewSessionWorktree",
|
||||
worktreePath: "/repo",
|
||||
});
|
||||
const persisted = presentationState(state);
|
||||
const encoded = JSON.stringify(persisted);
|
||||
expect(persisted.drafts.r).toBe("safe draft");
|
||||
expect(persisted.defaultNewSessionWorktree).toBe("/repo");
|
||||
expect(encoded).not.toContain("ephemeral");
|
||||
expect(encoded).not.toContain("messages");
|
||||
expect(encoded).not.toContain("extensions");
|
||||
});
|
||||
it("reads and removes legacy worktree/scale only after caller saves", () => {
|
||||
const getItem = vi.fn((key: string) =>
|
||||
key.includes("selected") ? "/repo" : "1.2",
|
||||
);
|
||||
expect(legacyPreferences({ getItem })).toEqual({
|
||||
path: "/repo",
|
||||
scale: 1.2,
|
||||
});
|
||||
const removeItem = vi.fn();
|
||||
removeLegacyPreferences({ removeItem });
|
||||
expect(removeItem).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { UiState, WorkspaceState } from "./types";
|
||||
|
||||
export const selectedPathKey = "pi-status-ui.selected-worktree";
|
||||
export const uiScaleKey = "pi-status-ui.interface-scale";
|
||||
|
||||
export function presentationState(state: WorkspaceState): UiState {
|
||||
const drafts: Record<string, string> = {};
|
||||
const scroll: UiState["scroll"] = {};
|
||||
const lastSeen: UiState["lastSeen"] = {};
|
||||
for (const [id, runtime] of Object.entries(state.runtimesById)) {
|
||||
if (runtime.draft) drafts[id] = runtime.draft;
|
||||
scroll[id] = runtime.scroll;
|
||||
if (!runtime.unread && state.bridge.instanceId)
|
||||
lastSeen[id] = {
|
||||
bridgeInstanceId: state.bridge.instanceId,
|
||||
seq: state.bridge.latestSeq,
|
||||
lastActivity: runtime.summary.lastActivity,
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
directoryOrder: state.directoryOrder,
|
||||
runtimeOrderByDirectory: state.runtimeOrderByDirectory,
|
||||
selectedRuntimeId: state.selectedRuntimeId,
|
||||
drafts,
|
||||
scroll,
|
||||
lastSeen,
|
||||
workspaceCursor: state.bridge.instanceId
|
||||
? {
|
||||
bridgeInstanceId: state.bridge.instanceId,
|
||||
seq: state.bridge.latestSeq,
|
||||
}
|
||||
: undefined,
|
||||
interfaceScale: state.interfaceScale,
|
||||
defaultNewSessionWorktree: state.defaultNewSessionWorktree,
|
||||
dismissedCollisionWarning: state.dismissedCollisionWarning,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadPresentation() {
|
||||
return invoke<UiState>("load_ui_state");
|
||||
}
|
||||
export async function savePresentation(state: UiState) {
|
||||
const current = await loadPresentation();
|
||||
await invoke("save_ui_state", {
|
||||
state: { ...state, quickPane: current.quickPane },
|
||||
});
|
||||
}
|
||||
export function legacyPreferences(storage: Pick<Storage, "getItem">) {
|
||||
const path = storage.getItem(selectedPathKey) ?? undefined;
|
||||
const raw = Number(storage.getItem(uiScaleKey));
|
||||
const scale =
|
||||
Number.isFinite(raw) && raw >= 0.9 && raw <= 1.4 ? raw : undefined;
|
||||
return { path, scale };
|
||||
}
|
||||
export function removeLegacyPreferences(storage: Pick<Storage, "removeItem">) {
|
||||
storage.removeItem(selectedPathKey);
|
||||
storage.removeItem(uiScaleKey);
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyWorkspaceEvent,
|
||||
initialWorkspaceState,
|
||||
reconcileWorkspace,
|
||||
workspaceReducer,
|
||||
} from "./reducer";
|
||||
import type { RuntimeSnapshot, Workspace } from "./types";
|
||||
const runtime = (id: string, path = "/repo") => ({
|
||||
runtimeId: id,
|
||||
worktreePath: path,
|
||||
state: "idle",
|
||||
label: id,
|
||||
attention: false,
|
||||
queueCount: 0,
|
||||
lastActivity: id,
|
||||
openedAt: id,
|
||||
});
|
||||
const workspace: Workspace = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
directories: [
|
||||
{
|
||||
worktreePath: "/repo",
|
||||
isHome: false,
|
||||
openCount: 2,
|
||||
workingCount: 0,
|
||||
attentionCount: 0,
|
||||
recoveringCount: 0,
|
||||
errorCount: 0,
|
||||
runtimes: [runtime("a"), runtime("b")],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("workspace reducer", () => {
|
||||
it("isolates drafts, snapshots and background unread by runtime", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "a",
|
||||
draft: "draft a",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "b",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
runtime: runtime("b"),
|
||||
transcript: { data: { messages: [{ role: "user", content: "b" }] } },
|
||||
extensions: [],
|
||||
};
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "b",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_event",
|
||||
data: {
|
||||
runtimeId: "b",
|
||||
eventType: "transcript",
|
||||
eventData: { event: { type: "message_end" } },
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.draft).toBe("draft a");
|
||||
expect(state.runtimesById.a.messages).toEqual([]);
|
||||
expect(state.runtimesById.b.messages[0].content).toBe("b");
|
||||
expect(state.runtimesById.b.unread).toBe(true);
|
||||
});
|
||||
it("uses the runtime summary as the authoritative snapshot working state", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: { ...runtime("a"), state: "streaming" },
|
||||
state: { data: { isStreaming: false } },
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.progress.phase).toBe("working");
|
||||
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 2,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 2,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 2,
|
||||
runtime: runtime("a"),
|
||||
state: { data: { isStreaming: true } },
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.progress.phase).toBe("idle");
|
||||
});
|
||||
it("requests another snapshot when a live event overtakes the current load", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_event",
|
||||
data: {
|
||||
runtimeId: "a",
|
||||
eventType: "queue",
|
||||
eventData: { event: { pendingMessageCount: 1 } },
|
||||
},
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
runtime: runtime("a"),
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.runtimesById.a.loading).toBe(false);
|
||||
expect(state.runtimesById.a.snapshotRetry).toBe(true);
|
||||
expect(state.runtimesById.a.summary.queueCount).toBe(1);
|
||||
});
|
||||
it("preserves known models and exposes model discovery failures", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
models: {
|
||||
data: { models: [{ provider: "openai", id: "gpt-5" }] },
|
||||
},
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 2,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 2,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
models: {
|
||||
data: { models: [] },
|
||||
error: "RPC timed out",
|
||||
},
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.runtimesById.a.models).toEqual([
|
||||
{ provider: "openai", id: "gpt-5" },
|
||||
]);
|
||||
expect(state.runtimesById.a.modelError).toContain("RPC timed out");
|
||||
});
|
||||
it("removes the optimistic submission when Pi returns its structured user message", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "submissionAdded",
|
||||
runtimeId: "a",
|
||||
submission: {
|
||||
id: 1,
|
||||
text: "Hello Pi",
|
||||
userMessageCount: 0,
|
||||
phase: "sent",
|
||||
},
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
transcript: {
|
||||
data: {
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "Hello Pi" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
extensions: [],
|
||||
};
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
expect(state.runtimesById.a.pendingSubmissions).toEqual([]);
|
||||
});
|
||||
it("reconciles duplicate pending submissions one message at a time and honors Pi queue totals", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
for (const id of [1, 2])
|
||||
state = workspaceReducer(state, {
|
||||
type: "submissionAdded",
|
||||
runtimeId: "a",
|
||||
submission: {
|
||||
id,
|
||||
text: "Continue",
|
||||
userMessageCount: 0,
|
||||
phase: "sent",
|
||||
},
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 1,
|
||||
runtime: runtime("a"),
|
||||
transcript: {
|
||||
data: { messages: [{ role: "user", content: "Continue" }] },
|
||||
},
|
||||
extensions: [],
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.pendingSubmissions).toHaveLength(1);
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_event",
|
||||
data: {
|
||||
runtimeId: "a",
|
||||
eventType: "queue",
|
||||
eventData: {
|
||||
event: {
|
||||
pendingMessageCount: 3,
|
||||
followUp: ["one"],
|
||||
steering: ["two"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(state.runtimesById.a.summary.queueCount).toBe(3);
|
||||
});
|
||||
it("keeps a hotkey-triggered new-session startup visible until completion", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "newSessionLaunch",
|
||||
launch: { phase: "starting", detail: "Starting a new Pi session…" },
|
||||
});
|
||||
expect(state.newSessionLaunch?.phase).toBe("starting");
|
||||
state = workspaceReducer(state, {
|
||||
type: "newSessionLaunch",
|
||||
launch: undefined,
|
||||
});
|
||||
expect(state.newSessionLaunch).toBeUndefined();
|
||||
});
|
||||
it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 2,
|
||||
});
|
||||
const snap: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "epoch",
|
||||
latestSeq: 0,
|
||||
runtime: runtime("a"),
|
||||
extensions: [],
|
||||
};
|
||||
expect(
|
||||
workspaceReducer(state, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot: snap,
|
||||
}).runtimesById.a.loading,
|
||||
).toBe(true);
|
||||
const first = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
data: { runtimeId: "a", runtime: runtime("a") },
|
||||
});
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
data: { runtimeId: "a" },
|
||||
}),
|
||||
).toBe(first);
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 3,
|
||||
data: { runtimeId: "a" },
|
||||
}).bridge.status,
|
||||
).toBe("resyncing");
|
||||
expect(
|
||||
applyWorkspaceEvent(first, {
|
||||
bridgeInstanceId: "new",
|
||||
seq: 2,
|
||||
data: { runtimeId: "a" },
|
||||
}).bridge.status,
|
||||
).toBe("resyncing");
|
||||
});
|
||||
it("selects an adjacent tab on close, preserves drafts, and keeps failed dormant runtimes", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace, {
|
||||
version: 1,
|
||||
directoryOrder: ["/repo"],
|
||||
runtimeOrderByDirectory: { "/repo": ["b", "a"] },
|
||||
selectedRuntimeId: "b",
|
||||
drafts: { a: "saved" },
|
||||
scroll: {},
|
||||
lastSeen: {},
|
||||
});
|
||||
expect(state.runtimesById.a.draft).toBe("saved");
|
||||
state = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_closed",
|
||||
data: { runtimeId: "b" },
|
||||
});
|
||||
expect(state.selectedRuntimeId).toBe("a");
|
||||
const failed: Workspace = {
|
||||
...workspace,
|
||||
directories: [
|
||||
{
|
||||
...workspace.directories[0],
|
||||
runtimes: [
|
||||
{
|
||||
...runtime("failed"),
|
||||
state: "failed",
|
||||
attention: true,
|
||||
error: { message: "missing" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed.summary
|
||||
.agentId,
|
||||
).toBeUndefined();
|
||||
});
|
||||
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state = workspaceReducer(state, {
|
||||
type: "draftChanged",
|
||||
runtimeId: "a",
|
||||
draft: "keep me",
|
||||
});
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: false,
|
||||
scrollTop: 42,
|
||||
});
|
||||
state.runtimesById.a.loadedThroughSeq = 100;
|
||||
state = workspaceReducer(state, {
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "resetRequired",
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
reason: "epochChanged",
|
||||
},
|
||||
});
|
||||
const restored = reconcileWorkspace(state, {
|
||||
...workspace,
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
});
|
||||
expect(restored.runtimesById.a.loadedThroughSeq).toBe(0);
|
||||
expect(restored.runtimesById.a.draft).toBe("keep me");
|
||||
expect(restored.runtimesById.a.scroll.scrollTop).toBe(42);
|
||||
const requested = workspaceReducer(restored, {
|
||||
type: "snapshotRequested",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
});
|
||||
const snapshot: RuntimeSnapshot = {
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 2,
|
||||
runtime: runtime("a"),
|
||||
extensions: [],
|
||||
};
|
||||
const accepted = workspaceReducer(requested, {
|
||||
type: "snapshotLoaded",
|
||||
runtimeId: "a",
|
||||
generation: 1,
|
||||
snapshot,
|
||||
});
|
||||
expect(accepted.runtimesById.a.loadedThroughSeq).toBe(2);
|
||||
expect(accepted.runtimesById.a.extensions).toEqual([]);
|
||||
expect(accepted.runtimesById.a.messages).toEqual([]);
|
||||
});
|
||||
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
|
||||
state.runtimesById.b.extensions = [{ id: "dead-request", method: "confirm" }];
|
||||
state.runtimesById.b.unread = true;
|
||||
const reset = workspaceReducer(state, {
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "resetRequired",
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 1,
|
||||
reason: "epochChanged",
|
||||
},
|
||||
});
|
||||
const restored = reconcileWorkspace(reset, {
|
||||
...workspace,
|
||||
bridgeInstanceId: "new-epoch",
|
||||
latestSeq: 1,
|
||||
});
|
||||
expect(restored.runtimesById.b.messages).toEqual([]);
|
||||
expect(restored.runtimesById.b.extensions).toEqual([]);
|
||||
expect(restored.runtimesById.b.unread).toBe(false);
|
||||
});
|
||||
it("clears active history and input when a runtime is renewed", () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById.a.messages = [
|
||||
{ role: "user", content: "old conversation" },
|
||||
];
|
||||
state.runtimesById.a.pendingSubmissions = [
|
||||
{
|
||||
id: 1,
|
||||
text: "old pending input",
|
||||
userMessageCount: 1,
|
||||
phase: "sent",
|
||||
},
|
||||
];
|
||||
state.runtimesById.a.extensions = [
|
||||
{ id: "old-extension", method: "confirm" },
|
||||
];
|
||||
state.runtimesById.a.models = [{ provider: "openai", id: "old-model" }];
|
||||
state.runtimesById.a.draft = "old draft";
|
||||
state.runtimesById.a.scroll = { atBottom: false, scrollTop: 88 };
|
||||
state.runtimesById.a.unread = true;
|
||||
|
||||
const renewed = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_renewed",
|
||||
data: { runtimeId: "a", runtime: runtime("a") },
|
||||
});
|
||||
|
||||
expect(renewed.runtimesById.a.messages).toEqual([]);
|
||||
expect(renewed.runtimesById.a.pendingSubmissions).toEqual([]);
|
||||
expect(renewed.runtimesById.a.extensions).toEqual([]);
|
||||
expect(renewed.runtimesById.a.models).toEqual([]);
|
||||
expect(renewed.runtimesById.a.draft).toBe("");
|
||||
expect(renewed.runtimesById.a.scroll).toEqual({ atBottom: true });
|
||||
expect(renewed.runtimesById.a.unread).toBe(false);
|
||||
expect(renewed.bridge.status).toBe("resyncing");
|
||||
});
|
||||
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
|
||||
const state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
const next = applyWorkspaceEvent(state, {
|
||||
bridgeInstanceId: "epoch",
|
||||
seq: 1,
|
||||
type: "runtime_opened",
|
||||
data: { runtimeId: "external", runtime: runtime("external") },
|
||||
});
|
||||
expect(next.bridge.status).toBe("resyncing");
|
||||
});
|
||||
it("clears unread only when selected, active, and at latest", () => {
|
||||
let state = reconcileWorkspace(initialWorkspaceState, workspace);
|
||||
state.runtimesById.a.unread = true;
|
||||
state = workspaceReducer(state, { type: "setWindowActive", active: false });
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(true);
|
||||
state = workspaceReducer(state, { type: "setWindowActive", active: true });
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: false,
|
||||
});
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(true);
|
||||
state = workspaceReducer(state, {
|
||||
type: "scrollChanged",
|
||||
runtimeId: "a",
|
||||
atBottom: true,
|
||||
});
|
||||
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
|
||||
expect(state.runtimesById.a.unread).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
import type {
|
||||
Extension,
|
||||
HostEvent,
|
||||
RuntimeSnapshot,
|
||||
RuntimeSummary,
|
||||
RuntimeView,
|
||||
UiState,
|
||||
Workspace,
|
||||
WorkspaceEvent,
|
||||
WorkspaceState,
|
||||
} from "./types";
|
||||
|
||||
const idleProgress = {
|
||||
phase: "idle" as const,
|
||||
detail: "Ready for your next prompt",
|
||||
toolCount: 0,
|
||||
};
|
||||
|
||||
function snapshotProgress(runtime: RuntimeSummary) {
|
||||
if (runtime.state === "streaming")
|
||||
return {
|
||||
phase: "working" as const,
|
||||
detail: "Thinking and preparing a response",
|
||||
toolCount: 0,
|
||||
};
|
||||
if (runtime.state === "recovering")
|
||||
return {
|
||||
phase: "recovering" as const,
|
||||
detail: "Restoring this Pi session",
|
||||
toolCount: 0,
|
||||
};
|
||||
if (runtime.state === "failed")
|
||||
return {
|
||||
phase: "error" as const,
|
||||
detail: runtime.error?.message ?? "This Pi session failed",
|
||||
toolCount: 0,
|
||||
};
|
||||
return idleProgress;
|
||||
}
|
||||
|
||||
function messageText(message: RuntimeView["messages"][number]) {
|
||||
if (typeof message.content === "string") return message.content;
|
||||
return (message.content ?? [])
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text ?? "")
|
||||
.join("");
|
||||
}
|
||||
|
||||
function reconcilePendingSubmissions(
|
||||
pendingSubmissions: RuntimeView["pendingSubmissions"],
|
||||
messages: RuntimeView["messages"],
|
||||
) {
|
||||
const userMessages = messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map(messageText);
|
||||
const consumed = new Set<number>();
|
||||
return pendingSubmissions.filter((pending) => {
|
||||
for (
|
||||
let index = pending.userMessageCount;
|
||||
index < userMessages.length;
|
||||
index++
|
||||
) {
|
||||
if (consumed.has(index) || userMessages[index] !== pending.text) continue;
|
||||
consumed.add(index);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function runtimeView(
|
||||
summary: RuntimeSummary,
|
||||
previous?: RuntimeView,
|
||||
): RuntimeView {
|
||||
return {
|
||||
summary,
|
||||
state: previous?.state ?? {},
|
||||
stats: previous?.stats ?? {},
|
||||
messages: previous?.messages ?? [],
|
||||
commands: previous?.commands ?? [],
|
||||
models: previous?.models ?? [],
|
||||
modelError: previous?.modelError,
|
||||
extensions: previous?.extensions ?? [],
|
||||
extensionStatuses: previous?.extensionStatuses ?? {},
|
||||
extensionWidgets: previous?.extensionWidgets ?? {},
|
||||
notifications: previous?.notifications ?? [],
|
||||
progress: previous?.progress ?? idleProgress,
|
||||
pendingSubmissions: previous?.pendingSubmissions ?? [],
|
||||
draft: previous?.draft ?? "",
|
||||
scroll: previous?.scroll ?? { atBottom: true },
|
||||
unread: previous?.unread ?? false,
|
||||
loading: previous?.loading ?? false,
|
||||
snapshotRetry: previous?.snapshotRetry ?? false,
|
||||
error: previous?.error,
|
||||
requestGeneration: previous?.requestGeneration ?? 0,
|
||||
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resetRuntimeView(
|
||||
summary: RuntimeSummary,
|
||||
previous: RuntimeView,
|
||||
): RuntimeView {
|
||||
return {
|
||||
...runtimeView(summary, previous),
|
||||
state: {},
|
||||
stats: {},
|
||||
messages: [],
|
||||
commands: [],
|
||||
models: [],
|
||||
modelError: undefined,
|
||||
extensions: [],
|
||||
extensionStatuses: {},
|
||||
extensionWidgets: {},
|
||||
notifications: [],
|
||||
pendingSubmissions: [],
|
||||
unread: false,
|
||||
progress: idleProgress,
|
||||
loading: false,
|
||||
snapshotRetry: false,
|
||||
error: undefined,
|
||||
requestGeneration: 0,
|
||||
loadedThroughSeq: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialWorkspaceState: WorkspaceState = {
|
||||
bridge: { latestSeq: 0, status: "connecting" },
|
||||
directoriesByPath: {},
|
||||
directoryOrder: [],
|
||||
runtimesById: {},
|
||||
runtimeOrderByDirectory: {},
|
||||
windowActive: true,
|
||||
resourceWarning: false,
|
||||
defaultNewSessionWorktree: undefined,
|
||||
dismissedCollisionWarning: false,
|
||||
interfaceScale: 1,
|
||||
};
|
||||
|
||||
function ordered(current: string[] | undefined, authoritative: string[]) {
|
||||
const valid = new Set(authoritative);
|
||||
return [
|
||||
...(current ?? []).filter((id) => valid.has(id)),
|
||||
...authoritative.filter((id) => !(current ?? []).includes(id)),
|
||||
];
|
||||
}
|
||||
|
||||
export function reconcileWorkspace(
|
||||
state: WorkspaceState,
|
||||
workspace: Workspace,
|
||||
persisted?: UiState,
|
||||
): WorkspaceState {
|
||||
const epochChanged = Boolean(
|
||||
state.bridge.instanceId &&
|
||||
state.bridge.instanceId !== workspace.bridgeInstanceId,
|
||||
);
|
||||
const directoriesByPath: WorkspaceState["directoriesByPath"] = {};
|
||||
const runtimesById: WorkspaceState["runtimesById"] = {};
|
||||
const runtimeOrderByDirectory: WorkspaceState["runtimeOrderByDirectory"] = {};
|
||||
for (const directory of workspace.directories) {
|
||||
directoriesByPath[directory.worktreePath] = directory;
|
||||
for (const runtime of directory.runtimes) {
|
||||
const previous = state.runtimesById[runtime.runtimeId];
|
||||
runtimesById[runtime.runtimeId] =
|
||||
epochChanged && previous
|
||||
? resetRuntimeView(runtime, previous)
|
||||
: runtimeView(runtime, previous);
|
||||
}
|
||||
const ids = directory.runtimes.map((runtime) => runtime.runtimeId);
|
||||
runtimeOrderByDirectory[directory.worktreePath] = ordered(
|
||||
persisted?.runtimeOrderByDirectory[directory.worktreePath] ??
|
||||
state.runtimeOrderByDirectory[directory.worktreePath],
|
||||
ids,
|
||||
);
|
||||
}
|
||||
const paths = workspace.directories.map((directory) => directory.worktreePath);
|
||||
const directoryOrder = ordered(
|
||||
persisted?.directoryOrder ?? state.directoryOrder,
|
||||
paths,
|
||||
);
|
||||
const preferred = persisted?.selectedRuntimeId ?? state.selectedRuntimeId;
|
||||
const selectedRuntimeId =
|
||||
preferred && runtimesById[preferred]
|
||||
? preferred
|
||||
: directoryOrder.flatMap((path) => runtimeOrderByDirectory[path] ?? [])[0];
|
||||
const selectedDirectoryPath = selectedRuntimeId
|
||||
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
||||
: directoryOrder[0];
|
||||
if (persisted) {
|
||||
for (const [id, view] of Object.entries(runtimesById)) {
|
||||
view.draft = persisted.drafts[id] ?? view.draft;
|
||||
view.scroll = persisted.scroll[id] ?? view.scroll;
|
||||
const seen = persisted.lastSeen[id];
|
||||
if (
|
||||
seen?.bridgeInstanceId === workspace.bridgeInstanceId &&
|
||||
seen.seq < workspace.latestSeq &&
|
||||
seen.lastActivity !== view.summary.lastActivity
|
||||
)
|
||||
view.unread = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
instanceId: workspace.bridgeInstanceId,
|
||||
latestSeq: workspace.latestSeq,
|
||||
status: "ready",
|
||||
},
|
||||
directoriesByPath,
|
||||
directoryOrder,
|
||||
runtimesById,
|
||||
runtimeOrderByDirectory,
|
||||
selectedRuntimeId,
|
||||
selectedDirectoryPath,
|
||||
dismissedCollisionWarning:
|
||||
persisted?.dismissedCollisionWarning ?? state.dismissedCollisionWarning,
|
||||
defaultNewSessionWorktree:
|
||||
persisted?.defaultNewSessionWorktree ?? state.defaultNewSessionWorktree,
|
||||
interfaceScale: persisted?.interfaceScale ?? state.interfaceScale,
|
||||
};
|
||||
}
|
||||
|
||||
function meaningful(eventType?: string, event?: Record<string, unknown>) {
|
||||
if (["extension_ui_request", "agent_state"].includes(eventType ?? ""))
|
||||
return true;
|
||||
if (eventType === "transcript")
|
||||
return ["message_end", "turn_end", "agent_end"].includes(
|
||||
String(event?.type ?? ""),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function extensionPresentation(view: RuntimeView, extension: Extension) {
|
||||
if (["select", "confirm", "input", "editor"].includes(extension.method ?? ""))
|
||||
view.extensions = [
|
||||
...view.extensions.filter((entry) => entry.id !== extension.id),
|
||||
extension,
|
||||
];
|
||||
else if (extension.method === "notify")
|
||||
view.notifications = [...view.notifications, extension].slice(-4);
|
||||
else if (extension.method === "setStatus" && extension.statusKey) {
|
||||
const next = { ...view.extensionStatuses };
|
||||
if (extension.statusText) next[extension.statusKey] = extension.statusText;
|
||||
else delete next[extension.statusKey];
|
||||
view.extensionStatuses = next;
|
||||
} else if (extension.method === "setWidget" && extension.widgetKey) {
|
||||
const next = { ...view.extensionWidgets };
|
||||
if (extension.widgetLines)
|
||||
next[extension.widgetKey] = {
|
||||
lines: extension.widgetLines,
|
||||
placement: extension.widgetPlacement ?? "aboveEditor",
|
||||
};
|
||||
else delete next[extension.widgetKey];
|
||||
view.extensionWidgets = next;
|
||||
} else if (extension.method === "set_editor_text")
|
||||
view.draft = extension.text ?? "";
|
||||
}
|
||||
|
||||
export function applyWorkspaceEvent(
|
||||
state: WorkspaceState,
|
||||
event: WorkspaceEvent,
|
||||
): WorkspaceState {
|
||||
if (
|
||||
state.bridge.instanceId &&
|
||||
event.bridgeInstanceId !== state.bridge.instanceId
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "resyncing",
|
||||
error: "Bridge restarted",
|
||||
},
|
||||
};
|
||||
if (event.seq <= state.bridge.latestSeq) return state;
|
||||
if (event.seq !== state.bridge.latestSeq + 1)
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "resyncing",
|
||||
error: "Workspace event gap",
|
||||
},
|
||||
};
|
||||
const id = event.data?.runtimeId;
|
||||
if (
|
||||
event.type === "runtime_renewed" &&
|
||||
id &&
|
||||
state.runtimesById[id] &&
|
||||
event.data?.runtime
|
||||
) {
|
||||
const renewed = resetRuntimeView(event.data.runtime, state.runtimesById[id]);
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
|
||||
runtimesById: {
|
||||
...state.runtimesById,
|
||||
[id]: {
|
||||
...renewed,
|
||||
draft: "",
|
||||
scroll: { atBottom: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
|
||||
event.type ?? "",
|
||||
)
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
|
||||
};
|
||||
if (!id || (!state.runtimesById[id] && !event.data?.runtime))
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
latestSeq: event.seq,
|
||||
status: "resyncing",
|
||||
error: "Unknown runtime",
|
||||
},
|
||||
};
|
||||
const runtimesById = { ...state.runtimesById };
|
||||
let view = runtimeView(
|
||||
event.data?.runtime ?? runtimesById[id].summary,
|
||||
runtimesById[id],
|
||||
);
|
||||
view = { ...view, summary: event.data?.runtime ?? view.summary };
|
||||
if (event.type === "runtime_closed") {
|
||||
delete runtimesById[id];
|
||||
const path = view.summary.worktreePath;
|
||||
const order = (state.runtimeOrderByDirectory[path] ?? []).filter(
|
||||
(runtimeId) => runtimeId !== id,
|
||||
);
|
||||
const runtimeOrderByDirectory = {
|
||||
...state.runtimeOrderByDirectory,
|
||||
[path]: order,
|
||||
};
|
||||
const selectedRuntimeId =
|
||||
state.selectedRuntimeId === id
|
||||
? (order[0] ?? Object.keys(runtimesById)[0])
|
||||
: state.selectedRuntimeId;
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq },
|
||||
runtimesById,
|
||||
runtimeOrderByDirectory,
|
||||
selectedRuntimeId,
|
||||
selectedDirectoryPath: selectedRuntimeId
|
||||
? runtimesById[selectedRuntimeId]?.summary.worktreePath
|
||||
: path,
|
||||
};
|
||||
}
|
||||
const type = event.data?.eventType;
|
||||
const data = event.data?.eventData;
|
||||
const payload = data?.event ?? {};
|
||||
if (type === "agent_state" && data?.state) {
|
||||
view.summary = { ...view.summary, state: data.state };
|
||||
view.progress =
|
||||
data.state === "streaming"
|
||||
? {
|
||||
...view.progress,
|
||||
phase: "working",
|
||||
detail: "Thinking and preparing a response",
|
||||
}
|
||||
: data.state === "recovering"
|
||||
? {
|
||||
...view.progress,
|
||||
phase: "recovering",
|
||||
detail: "Recovering the Pi session",
|
||||
}
|
||||
: data.state === "error"
|
||||
? { ...view.progress, phase: "error", detail: "Pi needs attention" }
|
||||
: idleProgress;
|
||||
} else if (type === "tool") {
|
||||
const ending = String(payload.type ?? "").endsWith("_end");
|
||||
view.progress = {
|
||||
...view.progress,
|
||||
phase: "working",
|
||||
detail: ending ? "Continuing after tool result" : "Running a tool",
|
||||
tool: ending ? undefined : String(payload.toolName ?? "tool"),
|
||||
toolCount:
|
||||
view.progress.toolCount +
|
||||
(String(payload.type ?? "").endsWith("_start") ? 1 : 0),
|
||||
};
|
||||
} else if (type === "queue") {
|
||||
const queueCount =
|
||||
typeof payload.pendingMessageCount === "number"
|
||||
? payload.pendingMessageCount
|
||||
: (Array.isArray(payload.followUp) ? payload.followUp.length : 0) +
|
||||
(Array.isArray(payload.steering) ? payload.steering.length : 0);
|
||||
view.summary = { ...view.summary, queueCount };
|
||||
view.progress = {
|
||||
...view.progress,
|
||||
queueCount,
|
||||
detail: queueCount
|
||||
? `${queueCount} follow-up${queueCount === 1 ? "" : "s"} queued`
|
||||
: view.progress.detail,
|
||||
};
|
||||
} else if (type === "extension_ui_request")
|
||||
extensionPresentation(view, payload as Extension);
|
||||
if (id !== state.selectedRuntimeId && meaningful(type, payload))
|
||||
view.unread = true;
|
||||
view.loadedThroughSeq = Math.max(view.loadedThroughSeq, event.seq);
|
||||
runtimesById[id] = view;
|
||||
return {
|
||||
...state,
|
||||
bridge: { ...state.bridge, latestSeq: event.seq, status: "ready" },
|
||||
runtimesById,
|
||||
};
|
||||
}
|
||||
|
||||
export type WorkspaceAction =
|
||||
| { type: "workspaceLoaded"; workspace: Workspace; persisted?: UiState }
|
||||
| { type: "hostEvent"; host: HostEvent }
|
||||
| { type: "snapshotRequested"; runtimeId: string; generation: number }
|
||||
| {
|
||||
type: "snapshotLoaded";
|
||||
runtimeId: string;
|
||||
generation: number;
|
||||
snapshot: RuntimeSnapshot;
|
||||
}
|
||||
| {
|
||||
type: "snapshotFailed";
|
||||
runtimeId: string;
|
||||
generation: number;
|
||||
error: string;
|
||||
}
|
||||
| { type: "selectRuntime"; runtimeId: string }
|
||||
| { type: "selectDirectory"; worktreePath: string }
|
||||
| { type: "draftChanged"; runtimeId: string; draft: string }
|
||||
| {
|
||||
type: "scrollChanged";
|
||||
runtimeId: string;
|
||||
atBottom: boolean;
|
||||
scrollTop?: number;
|
||||
}
|
||||
| { type: "markRead"; runtimeId: string }
|
||||
| { type: "setWindowActive"; active: boolean }
|
||||
| { type: "setScale"; scale: number }
|
||||
| { type: "setResourceWarning"; value: boolean }
|
||||
| { type: "setDefaultNewSessionWorktree"; worktreePath?: string }
|
||||
| { type: "newSessionLaunch"; launch?: WorkspaceState["newSessionLaunch"] }
|
||||
| { type: "dismissCollisionWarning" }
|
||||
| {
|
||||
type: "submissionAdded";
|
||||
runtimeId: string;
|
||||
submission: RuntimeView["pendingSubmissions"][number];
|
||||
}
|
||||
| {
|
||||
type: "submissionSent";
|
||||
runtimeId: string;
|
||||
id: number;
|
||||
delivery: "prompt" | "follow_up";
|
||||
}
|
||||
| { type: "submissionRemoved"; runtimeId: string; id: number };
|
||||
|
||||
export function workspaceReducer(
|
||||
state: WorkspaceState,
|
||||
action: WorkspaceAction,
|
||||
): WorkspaceState {
|
||||
if (action.type === "workspaceLoaded")
|
||||
return reconcileWorkspace(state, action.workspace, action.persisted);
|
||||
if (action.type === "hostEvent") {
|
||||
if (action.host.kind === "event")
|
||||
return applyWorkspaceEvent(state, action.host.event);
|
||||
if (action.host.kind === "resetRequired")
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
latestSeq: 0,
|
||||
status: "resyncing",
|
||||
error: action.host.reason,
|
||||
},
|
||||
};
|
||||
if (action.host.kind === "disconnected")
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
...state.bridge,
|
||||
status: "error",
|
||||
error: action.host.message,
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
bridge: {
|
||||
instanceId: action.host.bridgeInstanceId,
|
||||
latestSeq: Math.max(state.bridge.latestSeq, action.host.latestSeq),
|
||||
status: "ready",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (action.type === "selectDirectory")
|
||||
return {
|
||||
...state,
|
||||
selectedDirectoryPath: action.worktreePath,
|
||||
selectedRuntimeId: state.runtimeOrderByDirectory[action.worktreePath]?.[0],
|
||||
};
|
||||
if (action.type === "selectRuntime")
|
||||
return {
|
||||
...state,
|
||||
selectedRuntimeId: action.runtimeId,
|
||||
selectedDirectoryPath:
|
||||
state.runtimesById[action.runtimeId]?.summary.worktreePath,
|
||||
};
|
||||
if (action.type === "setWindowActive")
|
||||
return { ...state, windowActive: action.active };
|
||||
if (action.type === "setScale")
|
||||
return { ...state, interfaceScale: action.scale };
|
||||
if (action.type === "setResourceWarning")
|
||||
return { ...state, resourceWarning: action.value };
|
||||
if (action.type === "setDefaultNewSessionWorktree")
|
||||
return { ...state, defaultNewSessionWorktree: action.worktreePath };
|
||||
if (action.type === "newSessionLaunch")
|
||||
return { ...state, newSessionLaunch: action.launch };
|
||||
if (action.type === "dismissCollisionWarning")
|
||||
return { ...state, dismissedCollisionWarning: true };
|
||||
const runtimeId = "runtimeId" in action ? action.runtimeId : undefined;
|
||||
if (!runtimeId || !state.runtimesById[runtimeId]) return state;
|
||||
const view = { ...state.runtimesById[runtimeId] };
|
||||
if (action.type === "snapshotRequested") {
|
||||
view.loading = true;
|
||||
view.snapshotRetry = false;
|
||||
view.error = undefined;
|
||||
view.requestGeneration = action.generation;
|
||||
} else if (action.type === "snapshotFailed") {
|
||||
if (view.requestGeneration !== action.generation) return state;
|
||||
view.loading = false;
|
||||
view.snapshotRetry = false;
|
||||
view.error = action.error;
|
||||
} else if (action.type === "snapshotLoaded") {
|
||||
if (view.requestGeneration !== action.generation) return state;
|
||||
if (
|
||||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
|
||||
action.snapshot.latestSeq < view.loadedThroughSeq
|
||||
) {
|
||||
view.loading = false;
|
||||
view.snapshotRetry = true;
|
||||
return {
|
||||
...state,
|
||||
runtimesById: { ...state.runtimesById, [runtimeId]: view },
|
||||
};
|
||||
}
|
||||
view.loading = false;
|
||||
view.snapshotRetry = false;
|
||||
view.summary = action.snapshot.runtime;
|
||||
view.state = action.snapshot.state?.data ?? {};
|
||||
view.stats = action.snapshot.stats?.data ?? {};
|
||||
view.messages = action.snapshot.transcript?.data?.messages ?? [];
|
||||
view.pendingSubmissions = reconcilePendingSubmissions(
|
||||
view.pendingSubmissions,
|
||||
view.messages,
|
||||
);
|
||||
view.commands = action.snapshot.commands?.data?.commands ?? [];
|
||||
if (action.snapshot.models?.error) {
|
||||
view.modelError = `Could not load available Pi models: ${action.snapshot.models.error}`;
|
||||
} else {
|
||||
view.models = action.snapshot.models?.data?.models ?? [];
|
||||
view.modelError = undefined;
|
||||
}
|
||||
view.extensions = action.snapshot.extensions ?? [];
|
||||
view.loadedThroughSeq = action.snapshot.latestSeq;
|
||||
view.progress = snapshotProgress(action.snapshot.runtime);
|
||||
} else if (action.type === "draftChanged") view.draft = action.draft;
|
||||
else if (action.type === "scrollChanged")
|
||||
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
|
||||
else if (action.type === "markRead") {
|
||||
if (
|
||||
state.selectedRuntimeId === runtimeId &&
|
||||
state.windowActive &&
|
||||
view.scroll.atBottom
|
||||
)
|
||||
view.unread = false;
|
||||
} else if (action.type === "submissionAdded")
|
||||
view.pendingSubmissions = [...view.pendingSubmissions, action.submission];
|
||||
else if (action.type === "submissionSent")
|
||||
view.pendingSubmissions = view.pendingSubmissions.map((item) =>
|
||||
item.id === action.id
|
||||
? { ...item, phase: "sent", delivery: action.delivery }
|
||||
: item,
|
||||
);
|
||||
else if (action.type === "submissionRemoved")
|
||||
view.pendingSubmissions = view.pendingSubmissions.filter(
|
||||
(item) => item.id !== action.id,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
runtimesById: { ...state.runtimesById, [runtimeId]: view },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
export type RuntimeError = { code?: string; message?: string };
|
||||
|
||||
export type RuntimeSummary = {
|
||||
runtimeId: string;
|
||||
worktreePath: string;
|
||||
state: string;
|
||||
label: string;
|
||||
attention: boolean;
|
||||
queueCount: number;
|
||||
activeTool?: string;
|
||||
lastActivity: string;
|
||||
openedAt: string;
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
sessionPath?: string;
|
||||
error?: RuntimeError;
|
||||
};
|
||||
|
||||
export type DirectoryWorkspace = {
|
||||
worktreePath: string;
|
||||
isHome: boolean;
|
||||
openCount: number;
|
||||
workingCount: number;
|
||||
attentionCount: number;
|
||||
recoveringCount: number;
|
||||
errorCount: number;
|
||||
runtimes: RuntimeSummary[];
|
||||
};
|
||||
|
||||
export type Workspace = {
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
directories: DirectoryWorkspace[];
|
||||
issue?: { code?: string; message?: string };
|
||||
};
|
||||
|
||||
export type WorkspaceSummary = {
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
openCount: number;
|
||||
workingCount: number;
|
||||
attentionCount: number;
|
||||
recoveringCount: number;
|
||||
errorCount: number;
|
||||
directoryCount: number;
|
||||
resourceWarning: boolean;
|
||||
};
|
||||
|
||||
export type TodoTask = {
|
||||
id: number;
|
||||
subject: string;
|
||||
activeForm?: string;
|
||||
status: "pending" | "in_progress" | "completed" | "deleted";
|
||||
};
|
||||
export type Message = {
|
||||
role?: string;
|
||||
toolName?: string;
|
||||
details?: { tasks?: TodoTask[] };
|
||||
content?:
|
||||
| string
|
||||
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
|
||||
};
|
||||
export type AgentState = {
|
||||
isStreaming?: boolean;
|
||||
thinkingLevel?: string;
|
||||
sessionName?: string;
|
||||
sessionId?: string;
|
||||
messageCount?: number;
|
||||
pendingMessageCount?: number;
|
||||
model?: {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
contextWindow?: number;
|
||||
};
|
||||
};
|
||||
export type SessionStats = {
|
||||
tokens?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
contextUsage?: {
|
||||
tokens?: number | null;
|
||||
contextWindow?: number;
|
||||
percent?: number | null;
|
||||
};
|
||||
};
|
||||
export type Command = { name?: string; description?: string };
|
||||
export type Model = { provider?: string; id?: string; name?: string };
|
||||
export type Extension = {
|
||||
id?: string;
|
||||
method?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
prefill?: string;
|
||||
initialValue?: string;
|
||||
text?: string;
|
||||
notifyType?: "info" | "warning" | "error";
|
||||
statusKey?: string;
|
||||
statusText?: string;
|
||||
widgetKey?: string;
|
||||
widgetLines?: string[];
|
||||
widgetPlacement?: "aboveEditor" | "belowEditor";
|
||||
};
|
||||
export type WorkProgress = {
|
||||
phase: "idle" | "working" | "recovering" | "error";
|
||||
detail: string;
|
||||
tool?: string;
|
||||
queueCount?: number;
|
||||
toolCount: number;
|
||||
};
|
||||
export type PendingSubmission = {
|
||||
id: number;
|
||||
text: string;
|
||||
userMessageCount: number;
|
||||
phase: "sending" | "sent";
|
||||
delivery?: "prompt" | "follow_up";
|
||||
};
|
||||
export type RuntimeSnapshot = {
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
runtime: RuntimeSummary;
|
||||
state?: { data?: AgentState };
|
||||
stats?: { data?: SessionStats };
|
||||
transcript?: { data?: { messages?: Message[] } };
|
||||
commands?: { data?: { commands?: Command[] } };
|
||||
models?: { data?: { models?: Model[] }; error?: string };
|
||||
extensions: Extension[];
|
||||
};
|
||||
export type DirectorySession = {
|
||||
path: string;
|
||||
id: string;
|
||||
cwd: string;
|
||||
name?: string;
|
||||
modified: string;
|
||||
messageCount: number;
|
||||
firstMessage?: string;
|
||||
runtimeId?: string;
|
||||
};
|
||||
export type WorkspaceEvent = {
|
||||
bridgeInstanceId: string;
|
||||
seq: number;
|
||||
type?: string;
|
||||
agentId?: string;
|
||||
data?: {
|
||||
runtimeId?: string;
|
||||
worktreePath?: string;
|
||||
sessionId?: string;
|
||||
runtime?: RuntimeSummary;
|
||||
eventType?: string;
|
||||
eventData?: { state?: string; event?: Record<string, unknown> };
|
||||
recovery?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
export type HostEvent =
|
||||
| {
|
||||
kind: "connected";
|
||||
bridgeInstanceId: string;
|
||||
firstAvailableSeq: number;
|
||||
latestSeq: number;
|
||||
}
|
||||
| { kind: "event"; event: WorkspaceEvent }
|
||||
| {
|
||||
kind: "resetRequired";
|
||||
bridgeInstanceId: string;
|
||||
latestSeq: number;
|
||||
reason: string;
|
||||
}
|
||||
| { kind: "disconnected"; message: string; retryInMs: number };
|
||||
|
||||
export type RuntimeView = {
|
||||
summary: RuntimeSummary;
|
||||
state: AgentState;
|
||||
stats: SessionStats;
|
||||
messages: Message[];
|
||||
commands: Command[];
|
||||
models: Model[];
|
||||
modelError?: string;
|
||||
extensions: Extension[];
|
||||
extensionStatuses: Record<string, string>;
|
||||
extensionWidgets: Record<
|
||||
string,
|
||||
{ lines: string[]; placement: "aboveEditor" | "belowEditor" }
|
||||
>;
|
||||
notifications: Extension[];
|
||||
progress: WorkProgress;
|
||||
pendingSubmissions: PendingSubmission[];
|
||||
draft: string;
|
||||
scroll: { atBottom: boolean; scrollTop?: number };
|
||||
unread: boolean;
|
||||
loading: boolean;
|
||||
snapshotRetry: boolean;
|
||||
error?: string;
|
||||
requestGeneration: number;
|
||||
loadedThroughSeq: number;
|
||||
};
|
||||
|
||||
export type WorkspaceState = {
|
||||
bridge: {
|
||||
instanceId?: string;
|
||||
latestSeq: number;
|
||||
status: "connecting" | "ready" | "resyncing" | "error";
|
||||
error?: string;
|
||||
};
|
||||
directoriesByPath: Record<string, DirectoryWorkspace>;
|
||||
directoryOrder: string[];
|
||||
runtimesById: Record<string, RuntimeView>;
|
||||
runtimeOrderByDirectory: Record<string, string[]>;
|
||||
selectedDirectoryPath?: string;
|
||||
selectedRuntimeId?: string;
|
||||
windowActive: boolean;
|
||||
resourceWarning: boolean;
|
||||
newSessionLaunch?: { phase: "starting" | "error"; detail: string };
|
||||
defaultNewSessionWorktree?: string;
|
||||
dismissedCollisionWarning: boolean;
|
||||
interfaceScale: number;
|
||||
};
|
||||
|
||||
export type QuickPaneSettings = {
|
||||
workspace: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
followUpProvider: string;
|
||||
followUpModel: string;
|
||||
instructions: string;
|
||||
maximumSeconds: number;
|
||||
maximumToolCalls: number;
|
||||
};
|
||||
|
||||
export type UiState = {
|
||||
version: 1;
|
||||
directoryOrder: string[];
|
||||
runtimeOrderByDirectory: Record<string, string[]>;
|
||||
selectedRuntimeId?: string;
|
||||
drafts: Record<string, string>;
|
||||
scroll: Record<string, { atBottom: boolean; scrollTop?: number }>;
|
||||
lastSeen: Record<
|
||||
string,
|
||||
{ bridgeInstanceId: string; seq: number; lastActivity?: string }
|
||||
>;
|
||||
workspaceCursor?: { bridgeInstanceId: string; seq: number };
|
||||
interfaceScale?: number;
|
||||
defaultNewSessionWorktree?: string;
|
||||
dismissedCollisionWarning?: boolean;
|
||||
quickPane?: QuickPaneSettings;
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { initialWorkspaceState, workspaceReducer } from "./reducer";
|
||||
import {
|
||||
legacyPreferences,
|
||||
loadPresentation,
|
||||
presentationState,
|
||||
removeLegacyPreferences,
|
||||
savePresentation,
|
||||
} from "./persistence";
|
||||
import type {
|
||||
HostEvent,
|
||||
RuntimeSnapshot,
|
||||
UiState,
|
||||
Workspace,
|
||||
WorkspaceSummary,
|
||||
} from "./types";
|
||||
|
||||
export function useWorkspace() {
|
||||
const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const loadGeneration = useRef<Record<string, number>>({});
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
const initialized = useRef(false);
|
||||
const legacyMigrationPending = useRef(false);
|
||||
const disposed = useRef(true);
|
||||
const refreshGeneration = useRef(0);
|
||||
|
||||
const loadSnapshot = useCallback(
|
||||
async (runtimeId: string, options: { throwOnError?: boolean } = {}) => {
|
||||
if (disposed.current) return false;
|
||||
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
|
||||
loadGeneration.current[runtimeId] = generation;
|
||||
dispatch({ type: "snapshotRequested", runtimeId, generation });
|
||||
try {
|
||||
const snapshot = await invoke<RuntimeSnapshot>(
|
||||
"get_session_runtime_snapshot",
|
||||
{ runtimeId },
|
||||
);
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({
|
||||
type: "snapshotFailed",
|
||||
runtimeId,
|
||||
generation,
|
||||
error: String(error),
|
||||
});
|
||||
if (options.throwOnError) throw error;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const snapshotRetryKey = Object.values(state.runtimesById)
|
||||
.flatMap((runtime) =>
|
||||
runtime.snapshotRetry ? [runtime.summary.runtimeId] : [],
|
||||
)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.join("\n");
|
||||
useEffect(() => {
|
||||
if (!snapshotRetryKey) return;
|
||||
for (const runtimeId of snapshotRetryKey.split("\n"))
|
||||
void loadSnapshot(runtimeId);
|
||||
}, [snapshotRetryKey, loadSnapshot]);
|
||||
|
||||
const scheduleSnapshot = useCallback(
|
||||
(runtimeId: string) => {
|
||||
if (timers.current[runtimeId]) return;
|
||||
timers.current[runtimeId] = window.setTimeout(() => {
|
||||
delete timers.current[runtimeId];
|
||||
void loadSnapshot(runtimeId);
|
||||
}, 120);
|
||||
},
|
||||
[loadSnapshot],
|
||||
);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (initialUiState?: UiState, reloadAllSnapshots = false) => {
|
||||
const generation = ++refreshGeneration.current;
|
||||
const cancelled = () =>
|
||||
disposed.current || generation !== refreshGeneration.current;
|
||||
const workspace = await invoke<Workspace>("get_workspace");
|
||||
if (cancelled()) return;
|
||||
let nextPersisted = initialUiState;
|
||||
if (nextPersisted) {
|
||||
const legacy = legacyPreferences(localStorage);
|
||||
const candidate = nextPersisted.selectedRuntimeId
|
||||
? undefined
|
||||
: workspace.directories.find(
|
||||
(directory) => directory.worktreePath === legacy.path,
|
||||
)?.runtimes[0];
|
||||
nextPersisted = {
|
||||
...nextPersisted,
|
||||
...(candidate ? { selectedRuntimeId: candidate.runtimeId } : {}),
|
||||
...(nextPersisted.interfaceScale === undefined &&
|
||||
legacy.scale !== undefined
|
||||
? { interfaceScale: legacy.scale }
|
||||
: {}),
|
||||
};
|
||||
legacyMigrationPending.current = Boolean(
|
||||
(!initialUiState?.selectedRuntimeId && candidate) ||
|
||||
(initialUiState?.interfaceScale === undefined &&
|
||||
legacy.scale !== undefined),
|
||||
);
|
||||
}
|
||||
dispatch({
|
||||
type: "workspaceLoaded",
|
||||
workspace,
|
||||
persisted: nextPersisted,
|
||||
});
|
||||
const summary = await invoke<WorkspaceSummary>("get_workspace_summary");
|
||||
if (cancelled()) return;
|
||||
dispatch({ type: "setResourceWarning", value: summary.resourceWarning });
|
||||
const selected =
|
||||
nextPersisted?.selectedRuntimeId &&
|
||||
workspace.directories.some((directory) =>
|
||||
directory.runtimes.some(
|
||||
(runtime) => runtime.runtimeId === nextPersisted?.selectedRuntimeId,
|
||||
),
|
||||
)
|
||||
? nextPersisted.selectedRuntimeId
|
||||
: workspace.directories[0]?.runtimes[0]?.runtimeId;
|
||||
const runtimeIds = workspace.directories.flatMap((directory) =>
|
||||
directory.runtimes.map((runtime) => runtime.runtimeId),
|
||||
);
|
||||
for (const runtimeId of reloadAllSnapshots
|
||||
? runtimeIds
|
||||
: selected
|
||||
? [selected]
|
||||
: []) {
|
||||
if (cancelled()) return;
|
||||
void loadSnapshot(runtimeId);
|
||||
}
|
||||
if (cancelled()) return;
|
||||
await invoke("subscribe_workspace", {
|
||||
cursor: {
|
||||
bridgeInstanceId: workspace.bridgeInstanceId,
|
||||
seq: workspace.latestSeq,
|
||||
},
|
||||
});
|
||||
if (cancelled()) await invoke("unsubscribe_workspace").catch(() => {});
|
||||
},
|
||||
[loadSnapshot],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
disposed.current = false;
|
||||
let stop: (() => void) | undefined;
|
||||
let stopNewRuntime: (() => void) | undefined;
|
||||
void listen<{
|
||||
phase: "starting" | "ready" | "error";
|
||||
runtimeId?: string;
|
||||
detail: string;
|
||||
}>("workspace-new-session", (message) => {
|
||||
const launch = message.payload;
|
||||
if (launch.phase === "starting") {
|
||||
dispatch({
|
||||
type: "newSessionLaunch",
|
||||
launch: { phase: "starting", detail: launch.detail },
|
||||
});
|
||||
} else if (launch.phase === "ready" && launch.runtimeId) {
|
||||
dispatch({ type: "newSessionLaunch", launch: undefined });
|
||||
void refresh().then(() => {
|
||||
if (!disposed.current)
|
||||
dispatch({ type: "selectRuntime", runtimeId: launch.runtimeId! });
|
||||
});
|
||||
} else if (launch.phase === "error") {
|
||||
dispatch({
|
||||
type: "newSessionLaunch",
|
||||
launch: { phase: "error", detail: launch.detail },
|
||||
});
|
||||
}
|
||||
}).then((unlisten) => {
|
||||
if (disposed.current) unlisten();
|
||||
else stopNewRuntime = unlisten;
|
||||
});
|
||||
void listen<HostEvent>("workspace-bridge", (message) => {
|
||||
if (disposed.current) return;
|
||||
const host = message.payload;
|
||||
dispatch({ type: "hostEvent", host });
|
||||
if (host.kind === "resetRequired") void refresh(undefined, true);
|
||||
else if (host.kind === "event") {
|
||||
const runtimeId = host.event.data?.runtimeId;
|
||||
const eventType = host.event.type;
|
||||
const piType = String(host.event.data?.eventData?.event?.type ?? "");
|
||||
if (
|
||||
[
|
||||
"runtime_opened",
|
||||
"runtime_closed",
|
||||
"runtime_recovered",
|
||||
"runtime_renewed",
|
||||
"runtime_restore_failed",
|
||||
].includes(eventType ?? "")
|
||||
) {
|
||||
void refresh();
|
||||
} else if (
|
||||
runtimeId &&
|
||||
(host.event.data?.eventType === "transcript" ||
|
||||
piType === "agent_settled" ||
|
||||
piType === "compaction_end")
|
||||
) {
|
||||
scheduleSnapshot(runtimeId);
|
||||
}
|
||||
}
|
||||
}).then((unlisten) => {
|
||||
if (disposed.current) unlisten();
|
||||
else stop = unlisten;
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
const loaded = await loadPresentation();
|
||||
if (disposed.current) return;
|
||||
await refresh(loaded);
|
||||
if (disposed.current) return;
|
||||
initialized.current = true;
|
||||
} catch (error) {
|
||||
if (!disposed.current)
|
||||
dispatch({
|
||||
type: "hostEvent",
|
||||
host: {
|
||||
kind: "disconnected",
|
||||
message: String(error),
|
||||
retryInMs: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
})();
|
||||
const appWindow = getCurrentWindow();
|
||||
const visibility = () =>
|
||||
dispatch({
|
||||
type: "setWindowActive",
|
||||
active: document.visibilityState === "visible" && document.hasFocus(),
|
||||
});
|
||||
window.addEventListener("focus", visibility);
|
||||
window.addEventListener("blur", visibility);
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
return () => {
|
||||
disposed.current = true;
|
||||
refreshGeneration.current += 1;
|
||||
stop?.();
|
||||
stopNewRuntime?.();
|
||||
void invoke("unsubscribe_workspace").catch(() => {});
|
||||
window.removeEventListener("focus", visibility);
|
||||
window.removeEventListener("blur", visibility);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
Object.values(timers.current).forEach(clearTimeout);
|
||||
void appWindow;
|
||||
};
|
||||
}, [refresh, scheduleSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized.current) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
void savePresentation(presentationState(state))
|
||||
.then(() => {
|
||||
if (legacyMigrationPending.current) {
|
||||
removeLegacyPreferences(localStorage);
|
||||
legacyMigrationPending.current = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [
|
||||
state.directoryOrder,
|
||||
state.runtimeOrderByDirectory,
|
||||
state.selectedRuntimeId,
|
||||
state.defaultNewSessionWorktree,
|
||||
state.interfaceScale,
|
||||
state.bridge.latestSeq,
|
||||
state.runtimesById,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = state.selectedRuntimeId;
|
||||
if (!id) return;
|
||||
if (
|
||||
!state.runtimesById[id]?.loading &&
|
||||
!state.runtimesById[id]?.messages.length
|
||||
)
|
||||
void loadSnapshot(id);
|
||||
}, [state.selectedRuntimeId, loadSnapshot]);
|
||||
|
||||
return { state, dispatch, refresh, loadSnapshot, scheduleSnapshot };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({ plugins: [react()], test: { environment: "jsdom", setupFiles: ["./src/test-setup.ts"] } });
|
||||
Reference in New Issue
Block a user