Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12b9b83d51 | |||
| a792c577ef | |||
| 7cc15cca92 |
@@ -0,0 +1,18 @@
|
||||
# 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
|
||||
|
||||
- Documentation and repository metadata are being established for public collaboration.
|
||||
|
||||
## [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,148 @@
|
||||
# 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 one Pi RPC agent per selected 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" \
|
||||
--agent <agent-id>
|
||||
```
|
||||
|
||||
Install the adapter that matches your desktop: [`noctalia-plugin/`](noctalia-plugin/) supports v4 and [`noctalia-v5-plugin/`](noctalia-v5-plugin/) supports v5. Both adapters are presentation-only; neither starts Pi nor approves extension requests.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -16,16 +16,58 @@ local function socket_path()
|
||||
return runtime .. "/pi-status-bridge/bridge.sock"
|
||||
end
|
||||
|
||||
local bridge_start_in_flight = false
|
||||
local pending_ui_open = false
|
||||
|
||||
local function current_agent_id()
|
||||
return noctalia.state.get("agent_id")
|
||||
end
|
||||
|
||||
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")
|
||||
barWidget.setText("Pi UI unavailable")
|
||||
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
|
||||
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 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
|
||||
if result.exitCode ~= 0 then
|
||||
start_bridge(false)
|
||||
return
|
||||
end
|
||||
local home = noctalia.getenv("HOME")
|
||||
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
|
||||
if worktree == home then
|
||||
@@ -42,9 +84,7 @@ function update()
|
||||
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")
|
||||
start_bridge(false)
|
||||
return
|
||||
end
|
||||
if agent_id == nil then
|
||||
@@ -63,7 +103,7 @@ function update()
|
||||
request_in_flight = false
|
||||
if result.exitCode ~= 0 then
|
||||
noctalia.state.set("agent_id", nil)
|
||||
discover_home_agent(socket)
|
||||
start_bridge(false)
|
||||
barWidget.setGlyph("terminal-2")
|
||||
barWidget.setText("Pi reconnecting")
|
||||
barWidget.setTooltip("Refreshing the Home Pi agent")
|
||||
@@ -77,13 +117,5 @@ function update()
|
||||
end
|
||||
|
||||
function onClick()
|
||||
local command = "setsid -f env TMPDIR=/tmp " .. shell_quote(ui_binary())
|
||||
.. " --show >/tmp/pi-status-ui.log 2>&1"
|
||||
noctalia.runAsync(command, function(result)
|
||||
if result.exitCode ~= 0 then
|
||||
barWidget.setGlyph("alert-triangle")
|
||||
barWidget.setText("Pi UI unavailable")
|
||||
barWidget.setTooltip("Build Pi Status UI or set PI_STATUS_UI_BINARY")
|
||||
end
|
||||
end)
|
||||
start_bridge(true)
|
||||
end
|
||||
|
||||
+19
-1
@@ -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",
|
||||
@@ -14,6 +26,12 @@
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js && 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/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,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
|
||||
+158
-14
@@ -5,6 +5,7 @@ import {
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
stat,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
@@ -141,7 +142,7 @@ async function listSessionSummaries(sessionDir, currentPath) {
|
||||
.sort((left, right) => right.modified.localeCompare(left.modified));
|
||||
}
|
||||
|
||||
async function listDirectoryCatalog(sessionRoot, agentsByPath) {
|
||||
async function listDirectoryCatalog(sessionRoot, agentsByPath, homeWorktree) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(sessionRoot, { withFileTypes: true });
|
||||
@@ -174,6 +175,7 @@ async function listDirectoryCatalog(sessionRoot, agentsByPath) {
|
||||
return {
|
||||
worktreePath,
|
||||
state: agent?.state ?? "inactive",
|
||||
isHome: worktreePath === homeWorktree,
|
||||
...(agent ? { agentId: agent.id } : {}),
|
||||
};
|
||||
})
|
||||
@@ -249,11 +251,19 @@ export function createAgentRegistry({
|
||||
const agentsByPath = new Map();
|
||||
const agentsById = new Map();
|
||||
const inFlight = new Map();
|
||||
const forgettingPaths = new Map();
|
||||
const pendingForgetOperations = new Set();
|
||||
let canonicalHomeWorktree;
|
||||
let stopping = false;
|
||||
|
||||
async function ensureAgent(worktreePath) {
|
||||
if (stopping) throw new Error("agent registry is stopping");
|
||||
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
|
||||
throw new TypeError("worktreePath must be an absolute path");
|
||||
const canonicalPath = await realpath(worktreePath);
|
||||
if (stopping) throw new Error("agent registry is stopping");
|
||||
if (forgettingPaths.has(canonicalPath))
|
||||
throw new Error("directory is being forgotten");
|
||||
const existing = agentsByPath.get(canonicalPath);
|
||||
if (existing) return publicAgent(existing);
|
||||
const creating = inFlight.get(canonicalPath);
|
||||
@@ -276,10 +286,14 @@ export function createAgentRegistry({
|
||||
events: [],
|
||||
listeners: new Set(),
|
||||
nextSequence: 1,
|
||||
stateVersion: 0,
|
||||
adapter: undefined,
|
||||
stopped: false,
|
||||
recoveryPromise: undefined,
|
||||
supervisor: undefined,
|
||||
forgetting: false,
|
||||
activeOperations: 0,
|
||||
operationWaiters: new Set(),
|
||||
};
|
||||
const publish = (type, data) => {
|
||||
const event = {
|
||||
@@ -304,12 +318,14 @@ export function createAgentRegistry({
|
||||
typeof event.data?.state === "string"
|
||||
) {
|
||||
agent.state = event.data.state;
|
||||
agent.stateVersion += 1;
|
||||
if (event.data.state === "idle") agent.supervisor.markHealthy();
|
||||
}
|
||||
publish(event.type, event.data);
|
||||
},
|
||||
onError: (error) => {
|
||||
agent.state = "error";
|
||||
agent.stateVersion += 1;
|
||||
publish("agent_state", {
|
||||
state: "error",
|
||||
error: { code: error.code ?? "unknown", message: error.message },
|
||||
@@ -323,6 +339,7 @@ export function createAgentRegistry({
|
||||
.then((adapterAfterRecovery) => {
|
||||
if (agent.stopped) return undefined;
|
||||
agent.state = adapterAfterRecovery ? "idle" : "failed";
|
||||
agent.stateVersion += 1;
|
||||
publish("agent_state", { state: agent.state });
|
||||
return adapterAfterRecovery;
|
||||
});
|
||||
@@ -332,6 +349,7 @@ export function createAgentRegistry({
|
||||
agent.adapter = adapter;
|
||||
const state = await adapter.send({ type: "get_state" });
|
||||
agent.state = state?.data?.isStreaming ? "streaming" : "idle";
|
||||
agent.stateVersion += 1;
|
||||
const sessionPath = state?.data?.sessionFile;
|
||||
if (typeof sessionPath === "string" && path.isAbsolute(sessionPath)) {
|
||||
agent.sessionPath = sessionPath;
|
||||
@@ -367,6 +385,25 @@ export function createAgentRegistry({
|
||||
return agent;
|
||||
}
|
||||
|
||||
async function withAgentOperation(agent, operation) {
|
||||
if (agent.forgetting) throw new Error("directory is being forgotten");
|
||||
agent.activeOperations += 1;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
agent.activeOperations -= 1;
|
||||
if (agent.activeOperations === 0) {
|
||||
for (const resolve of agent.operationWaiters) resolve();
|
||||
agent.operationWaiters.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForAgentOperations(agent) {
|
||||
if (agent.activeOperations === 0) return Promise.resolve();
|
||||
return new Promise((resolve) => agent.operationWaiters.add(resolve));
|
||||
}
|
||||
|
||||
async function refreshAgentSessionReference(agent) {
|
||||
const state = await agent.adapter.send({ type: "get_state" });
|
||||
const sessionPath = state?.data?.sessionFile;
|
||||
@@ -417,9 +454,78 @@ export function createAgentRegistry({
|
||||
return response;
|
||||
}
|
||||
|
||||
async function forgetDirectory(worktreePath) {
|
||||
if (stopping) throw new Error("agent registry is stopping");
|
||||
if (typeof worktreePath !== "string" || !path.isAbsolute(worktreePath))
|
||||
throw new TypeError("worktreePath must be an absolute path");
|
||||
let finishPendingForget;
|
||||
const pendingForget = new Promise((resolve) => {
|
||||
finishPendingForget = resolve;
|
||||
});
|
||||
pendingForgetOperations.add(pendingForget);
|
||||
try {
|
||||
let canonicalPath = path.normalize(worktreePath);
|
||||
if (!agentsByPath.has(canonicalPath)) {
|
||||
try {
|
||||
canonicalPath = await realpath(worktreePath);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
const homePath = canonicalHomeWorktree ?? (await realpath(homeWorktree));
|
||||
if (canonicalPath === homePath)
|
||||
throw new Error("cannot forget the home directory");
|
||||
|
||||
const existingForget = forgettingPaths.get(canonicalPath);
|
||||
if (existingForget) return existingForget;
|
||||
const forgetting = (async () => {
|
||||
try {
|
||||
const creation = inFlight.get(canonicalPath);
|
||||
if (creation) await creation;
|
||||
const agent = agentsByPath.get(canonicalPath);
|
||||
if (agent) {
|
||||
agent.forgetting = true;
|
||||
await waitForAgentOperations(agent);
|
||||
if (agent.state === "streaming")
|
||||
throw new Error("cannot forget a directory while Pi is working");
|
||||
agent.stopped = true;
|
||||
agent.supervisor.stop();
|
||||
await agent.adapter.stop();
|
||||
agent.state = "stopped";
|
||||
agent.stateVersion += 1;
|
||||
agent.listeners.clear();
|
||||
agentsByPath.delete(canonicalPath);
|
||||
agentsById.delete(agent.id);
|
||||
}
|
||||
const sessionDir = sessionDirectoryFor(sessionRoot, canonicalPath);
|
||||
await rm(path.join(sessionDir, SESSION_REFERENCE_FILE), {
|
||||
force: true,
|
||||
});
|
||||
return { worktreePath: canonicalPath };
|
||||
} catch (error) {
|
||||
const agent = agentsByPath.get(canonicalPath);
|
||||
if (agent) agent.forgetting = false;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
forgettingPaths.set(canonicalPath, forgetting);
|
||||
try {
|
||||
return await forgetting;
|
||||
} finally {
|
||||
if (forgettingPaths.get(canonicalPath) === forgetting)
|
||||
forgettingPaths.delete(canonicalPath);
|
||||
}
|
||||
} finally {
|
||||
pendingForgetOperations.delete(pendingForget);
|
||||
finishPendingForget();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async start() {
|
||||
return ensureAgent(homeWorktree);
|
||||
const agent = await ensureAgent(homeWorktree);
|
||||
canonicalHomeWorktree = agent.worktreePath;
|
||||
return agent;
|
||||
},
|
||||
async selectWorktree(worktreePath) {
|
||||
return ensureAgent(worktreePath);
|
||||
@@ -432,8 +538,13 @@ export function createAgentRegistry({
|
||||
);
|
||||
},
|
||||
async listDirectories() {
|
||||
return listDirectoryCatalog(sessionRoot, agentsByPath);
|
||||
return listDirectoryCatalog(
|
||||
sessionRoot,
|
||||
agentsByPath,
|
||||
canonicalHomeWorktree,
|
||||
);
|
||||
},
|
||||
forgetDirectory,
|
||||
async listSessions(agentId) {
|
||||
const agent = getAgent(agentId);
|
||||
return listSessionSummaries(agent.sessionDir, agent.sessionPath);
|
||||
@@ -459,30 +570,63 @@ export function createAgentRegistry({
|
||||
},
|
||||
async retry(agentId) {
|
||||
const agent = getAgent(agentId);
|
||||
agent.supervisor.markHealthy();
|
||||
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
|
||||
await agent.recoveryPromise;
|
||||
return publicAgent(agent);
|
||||
return withAgentOperation(agent, async () => {
|
||||
agent.supervisor.markHealthy();
|
||||
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
|
||||
await agent.recoveryPromise;
|
||||
return publicAgent(agent);
|
||||
});
|
||||
},
|
||||
async restart(agentId) {
|
||||
const agent = getAgent(agentId);
|
||||
await agent.adapter.stop();
|
||||
return this.retry(agentId);
|
||||
return withAgentOperation(agent, async () => {
|
||||
await agent.adapter.stop();
|
||||
agent.supervisor.markHealthy();
|
||||
agent.recoveryPromise = agent.supervisor.handleUnexpectedExit();
|
||||
await agent.recoveryPromise;
|
||||
return publicAgent(agent);
|
||||
});
|
||||
},
|
||||
async route(agentId, operation, payload) {
|
||||
const agent = getAgent(agentId);
|
||||
if (operation === "switch_session")
|
||||
return switchSession(agent, payload.sessionPath);
|
||||
if (operation === "new_session") return newSession(agent);
|
||||
return routeCommand(agent.adapter, operation, payload, agent.state);
|
||||
return withAgentOperation(agent, async () => {
|
||||
if (operation === "switch_session")
|
||||
return switchSession(agent, payload.sessionPath);
|
||||
if (operation === "new_session") return newSession(agent);
|
||||
const previousState = agent.state;
|
||||
const previousStateVersion = agent.stateVersion;
|
||||
const startsWork =
|
||||
operation === "prompt" ||
|
||||
(operation === "submit_prompt" && previousState !== "streaming");
|
||||
if (startsWork) agent.state = "streaming";
|
||||
try {
|
||||
return await routeCommand(
|
||||
agent.adapter,
|
||||
operation,
|
||||
payload,
|
||||
previousState,
|
||||
);
|
||||
} catch (error) {
|
||||
if (startsWork && agent.stateVersion === previousStateVersion)
|
||||
agent.state = previousState;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
async stop() {
|
||||
stopping = true;
|
||||
await Promise.allSettled([...inFlight.values()]);
|
||||
await Promise.all([...pendingForgetOperations]);
|
||||
const agents = [...agentsById.values()];
|
||||
for (const agent of agents) agent.forgetting = true;
|
||||
await Promise.all(agents.map(waitForAgentOperations));
|
||||
await Promise.all(
|
||||
[...agentsById.values()].map(async (agent) => {
|
||||
agents.map(async (agent) => {
|
||||
agent.stopped = true;
|
||||
agent.supervisor.stop();
|
||||
await agent.adapter.stop();
|
||||
agent.state = "stopped";
|
||||
agent.stateVersion += 1;
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -30,6 +30,8 @@ export async function startBridgeService({
|
||||
return {
|
||||
agent: await registry.selectWorktree(request.payload.worktreePath),
|
||||
};
|
||||
case "forget_directory":
|
||||
return registry.forgetDirectory(request.payload.worktreePath);
|
||||
case "subscribe":
|
||||
return {
|
||||
events: registry.eventsAfter(
|
||||
|
||||
@@ -7,6 +7,7 @@ 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_state", { agent: true, payload: "none" }],
|
||||
["get_session_stats", { agent: true, payload: "none" }],
|
||||
["list_sessions", { agent: true, payload: "none" }],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+254
-2
@@ -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";
|
||||
@@ -72,6 +72,253 @@ test("starts only the home agent and creates other worktree agents on explicit s
|
||||
assert.ok(fixture.calls.every(({ adapter }) => adapter.stopped));
|
||||
});
|
||||
|
||||
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 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 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;
|
||||
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
|
||||
releaseFeatureCreation();
|
||||
await selecting;
|
||||
await forgettingDuringCreation;
|
||||
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();
|
||||
};
|
||||
const firstForget = registry.forgetDirectory(worktrees.feature);
|
||||
const secondForget = registry.forgetDirectory(worktrees.feature);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await assert.rejects(
|
||||
registry.selectWorktree(worktrees.feature),
|
||||
/being forgotten/,
|
||||
);
|
||||
releaseStop();
|
||||
await Promise.all([firstForget, secondForget]);
|
||||
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",
|
||||
});
|
||||
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(adapter.stopped, false);
|
||||
releasePrompt({ type: "response", success: true });
|
||||
await prompt;
|
||||
await assert.rejects(forgettingDuringPrompt, /while Pi is working/);
|
||||
assert.equal(adapter.stopped, false);
|
||||
|
||||
options.onEvent({ type: "agent_state", data: { state: "idle" } });
|
||||
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;
|
||||
await assert.rejects(
|
||||
registry.forgetDirectory(worktrees.feature),
|
||||
/while Pi is working/,
|
||||
);
|
||||
failedFixture.options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
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();
|
||||
@@ -151,10 +398,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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -106,6 +106,20 @@ test("starts the home agent and dispatches local protocol requests to the select
|
||||
{ type: "get_state" },
|
||||
{ type: "prompt", message: "Use this worktree" },
|
||||
]);
|
||||
fixture.calls[1].options.onEvent({
|
||||
type: "agent_state",
|
||||
data: { state: "idle" },
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -13,9 +13,16 @@ 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, /Pi reconnecting/);
|
||||
assert.match(bridge, /discover_home_agent\(socket\)/);
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -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" })),
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
+176
-4
@@ -23,6 +23,20 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
||||
assert.match(source, /stripAnsi\(extension\.statusText\)/);
|
||||
assert.match(source, /className="pi-controls"/);
|
||||
assert.match(source, /aria-label="Model and thinking controls"/);
|
||||
assert.match(source, /const uiScaleKey/);
|
||||
assert.match(source, /function clampUiScale/);
|
||||
assert.match(source, /function updateUiScale/);
|
||||
assert.match(source, /localStorage\.setItem\(uiScaleKey/);
|
||||
assert.match(source, /className="scale-settings"/);
|
||||
assert.match(source, /aria-label="Interface scale"/);
|
||||
assert.match(source, /aria-label="Decrease interface scale"/);
|
||||
assert.match(source, /aria-label="Increase interface scale"/);
|
||||
assert.match(source, /type="range"/);
|
||||
assert.match(source, /style=\{\{ zoom: uiScale \}\}/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/className="pi-controls"[\s\S]*aria-label="Interface scale"/,
|
||||
);
|
||||
assert.doesNotMatch(source, /<details className="pi-controls">/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
@@ -35,12 +49,32 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
||||
);
|
||||
assert.match(source, /useLayoutEffect/);
|
||||
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
|
||||
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
|
||||
assert.match(source, /requestAnimationFrame\(scrollTranscriptToLatest\)/);
|
||||
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/const bridgeRetryTimer = useRef<number \| undefined>\(undefined\)/,
|
||||
);
|
||||
assert.match(source, /const bridgeRetryAttempt = useRef\(0\)/);
|
||||
assert.match(source, /function scheduleBridgeReconnect/);
|
||||
assert.match(source, /Bridge unavailable; retrying in/);
|
||||
assert.match(
|
||||
source,
|
||||
/setStatus\(String\(error\)\);\s*scheduleBridgeReconnect\(\);/,
|
||||
);
|
||||
assert.match(source, /bridgeRetryTimer\.current = window\.setTimeout/);
|
||||
assert.match(source, /void refreshAgents\(preferredPath\)/);
|
||||
assert.match(source, /bridgeRetryAttempt\.current = 0/);
|
||||
assert.match(
|
||||
source,
|
||||
/const lastEventSequence = useRef<Record<string, number>>/,
|
||||
);
|
||||
assert.match(source, /const eventReloadInFlight = useRef\(false\)/);
|
||||
assert.match(source, /const queuedEventReload = useRef<string \| undefined>/);
|
||||
assert.match(source, /function reloadAgentAfterEvent\(agentId: string\)/);
|
||||
assert.match(source, /void reloadAgentAfterEvent\(selectedId\)/);
|
||||
assert.match(source, /let disposed = false;/);
|
||||
assert.match(source, /if \(disposed\) stop\(\);/);
|
||||
assert.match(source, /generation !== loadGeneration\.current/);
|
||||
assert.match(source, /current\.phase !== "working"/);
|
||||
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
|
||||
@@ -62,6 +96,52 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
||||
);
|
||||
});
|
||||
|
||||
test("exposes accessible directory, session, notification, and extension controls", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(component, /aria-expanded=\{view === "settings"\}/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-label="Managed directories"\s+className="directory-tab-list"\s+role="tablist"/,
|
||||
);
|
||||
assert.match(component, /role="tab"/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-selected=\{directory\.agentId === selectedId\}/,
|
||||
);
|
||||
assert.match(component, /aria-controls="directory-workspace"/);
|
||||
assert.match(component, /onKeyDown=\{\(event\) => handleDirectoryTabKeyDown/);
|
||||
assert.match(
|
||||
component,
|
||||
/aria-current=\{session\.isCurrent \? "page" : undefined\}/,
|
||||
);
|
||||
assert.match(component, /role="status" aria-atomic="true"/);
|
||||
assert.match(component, /aria-hidden="true"/);
|
||||
assert.match(
|
||||
component,
|
||||
/role=\{notice\.notifyType === "error" \? "alert" : "status"\}/,
|
||||
);
|
||||
assert.match(component, /role="dialog"/);
|
||||
assert.match(component, /aria-modal="true"/);
|
||||
assert.match(component, /aria-labelledby="extension-title"/);
|
||||
assert.match(component, /const extensionRef = useRef<HTMLElement>\(null\)/);
|
||||
assert.match(component, /function focusableExtensionControls/);
|
||||
assert.match(component, /event\.key !== "Tab"/);
|
||||
assert.match(component, /data-initial-focus/);
|
||||
assert.match(component, /className="extension-backdrop"/);
|
||||
assert.match(component, /htmlFor="extension-value"/);
|
||||
assert.match(component, /Close current session and start a new session/);
|
||||
assert.match(component, /Restart Pi for the selected directory/);
|
||||
assert.match(component, /window\.confirm\(/);
|
||||
assert.match(styles, /button:focus-visible/);
|
||||
assert.match(styles, /\*::before,\n\*::after/);
|
||||
assert.match(styles, /\.sr-only/);
|
||||
assert.match(styles, /\.extension-backdrop \{/);
|
||||
assert.match(styles, /animation-duration: 0\.01ms/);
|
||||
});
|
||||
|
||||
test("makes the window draggable from the non-interactive header", async () => {
|
||||
const [component, styles, capability] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
@@ -110,11 +190,47 @@ test("shows bridge-managed directory status and sessions for the opened director
|
||||
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
|
||||
);
|
||||
assert.match(component, /function chooseDirectory/);
|
||||
assert.match(component, /function handleDirectoryTabKeyDown/);
|
||||
assert.match(component, /function forgetDirectory/);
|
||||
assert.match(component, /window\.confirm/);
|
||||
assert.match(component, /"forget_directory"/);
|
||||
assert.match(component, /bridgeEvent\.type === "agent_state"/);
|
||||
assert.match(component, /setDirectories\(\(current\) =>/);
|
||||
assert.match(
|
||||
component,
|
||||
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
|
||||
);
|
||||
assert.match(component, /className="directory-add"/);
|
||||
assert.match(
|
||||
component,
|
||||
/import \{ open \} from "@tauri-apps\/plugin-dialog"/,
|
||||
);
|
||||
assert.match(component, /function browseForFolder/);
|
||||
assert.match(component, /directory: true/);
|
||||
assert.match(component, /multiple: false/);
|
||||
assert.match(component, /Browse…/);
|
||||
assert.match(component, /const \[isAddingDirectory, setIsAddingDirectory\]/);
|
||||
assert.match(component, /aria-busy=\{isAddingDirectory\}/);
|
||||
assert.match(component, /className="directory-add-status" role="status"/);
|
||||
assert.match(component, /pendingDirectoryPath/);
|
||||
assert.match(component, /Added \$\{worktreeLabel\(pendingDirectoryPath\)\}/);
|
||||
assert.match(component, /Add directory/);
|
||||
assert.doesNotMatch(component, /className="folder-form"/);
|
||||
assert.doesNotMatch(component, /Remembered folders/);
|
||||
assert.match(component, /function startNewSession/);
|
||||
assert.match(component, /function closeCurrentSession/);
|
||||
assert.match(component, /Use \/resume to reopen it/);
|
||||
assert.match(component, /function switchSession/);
|
||||
assert.match(component, /className="session-panel"/);
|
||||
assert.match(component, /New session/);
|
||||
assert.match(component, /state\.isStreaming/);
|
||||
assert.match(styles, /\.directory-add \{/);
|
||||
assert.match(styles, /\.directory-add-status \{/);
|
||||
assert.match(styles, /\.directory-add-spinner \{/);
|
||||
assert.match(styles, /directory-add-spin/);
|
||||
assert.match(styles, /\.directory-tab \{/);
|
||||
assert.match(styles, /\.directory-tab-button \{/);
|
||||
assert.match(styles, /\.directory-close \{/);
|
||||
assert.match(styles, /\.session-panel \{/);
|
||||
assert.match(styles, /\.session-list \{/);
|
||||
});
|
||||
@@ -155,6 +271,28 @@ test("shows an optimistic prompt until the transcript receives it", async () =>
|
||||
assert.match(styles, /\.pending-message\.sending/);
|
||||
});
|
||||
|
||||
test("offers a lower-center control when the conversation is above its latest message", async () => {
|
||||
const [component, styles] = await Promise.all([
|
||||
readFile("ui/src/App.tsx", "utf8"),
|
||||
readFile("ui/src/App.css", "utf8"),
|
||||
]);
|
||||
assert.match(
|
||||
component,
|
||||
/const \[isTranscriptAtBottom, setIsTranscriptAtBottom\]/,
|
||||
);
|
||||
assert.match(component, /function updateTranscriptScrollPosition/);
|
||||
assert.match(component, /function scrollTranscriptToLatest/);
|
||||
assert.match(component, /onScroll=\{updateTranscriptScrollPosition\}/);
|
||||
assert.match(component, /!isTranscriptAtBottom/);
|
||||
assert.match(component, /aria-label="Scroll to latest message"/);
|
||||
assert.match(component, /className="scroll-to-latest"/);
|
||||
assert.match(component, /scrollTranscriptToLatest\(\);/);
|
||||
assert.match(styles, /\.transcript-container \{/);
|
||||
assert.match(styles, /\.scroll-to-latest \{[\s\S]*left: 50%/);
|
||||
assert.match(styles, /\.scroll-to-latest \{[\s\S]*bottom: 14px/);
|
||||
assert.match(styles, /\.scroll-to-latest \{[\s\S]*border-radius: 50%/);
|
||||
});
|
||||
|
||||
test("distinguishes user and assistant messages", async () => {
|
||||
const styles = await readFile("ui/src/App.css", "utf8");
|
||||
assert.match(styles, /\.message\.user \{/);
|
||||
@@ -163,11 +301,18 @@ test("distinguishes user and assistant messages", async () => {
|
||||
assert.match(styles, /background: #1a241a/);
|
||||
});
|
||||
|
||||
test("uses a subtle focus treatment for the prompt editor", async () => {
|
||||
test("keeps an accessible 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.doesNotMatch(styles, /outline: none/);
|
||||
assert.match(styles, /border-color: #6f8b56/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.composer textarea:focus \{[^}]*outline: 2px solid #c4ed8b/,
|
||||
);
|
||||
assert.match(styles, /\.composer textarea:focus \{[^}]*outline-offset: -2px/);
|
||||
assert.doesNotMatch(styles, /\.composer textarea:focus \{[^}]*box-shadow:/);
|
||||
assert.match(styles, /textarea:focus-visible/);
|
||||
});
|
||||
|
||||
test("submits the composer with Enter and preserves Shift+Enter for newlines", async () => {
|
||||
@@ -206,9 +351,36 @@ test("uses a compact workflow layout that keeps the composer and transcript stab
|
||||
assert.match(styles, /conic-gradient\(/);
|
||||
assert.match(styles, /transcript-border-orbit/);
|
||||
assert.match(styles, /prefers-reduced-motion: reduce/);
|
||||
assert.match(styles, /font-size: 18px/);
|
||||
assert.match(styles, /\.directory-tab-button \{[\s\S]*font-size: 13px/);
|
||||
assert.match(styles, /\.message pre \{[\s\S]*font-size: 14px/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/,
|
||||
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/,
|
||||
);
|
||||
assert.match(styles, /\.scale-settings \{/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/,
|
||||
);
|
||||
assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/);
|
||||
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
|
||||
});
|
||||
|
||||
test("configures a larger desktop window and enables the folder dialog", async () => {
|
||||
const [config, capability, rust] = await Promise.all([
|
||||
readFile("ui/src-tauri/tauri.conf.json", "utf8"),
|
||||
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
|
||||
readFile("ui/src-tauri/src/lib.rs", "utf8"),
|
||||
]);
|
||||
assert.match(config, /"width": 1100/);
|
||||
assert.match(config, /"height": 840/);
|
||||
assert.match(config, /"minWidth": 640/);
|
||||
assert.match(config, /"minHeight": 520/);
|
||||
assert.match(capability, /"dialog:default"/);
|
||||
assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/);
|
||||
});
|
||||
|
||||
+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
+13
-2
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"name": "pi-status-ui",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ui",
|
||||
"name": "pi-status-ui",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
@@ -1434,6 +1436,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
|
||||
+14
-6
@@ -2,25 +2,33 @@
|
||||
"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",
|
||||
"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",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4",
|
||||
"@tauri-apps/cli": "^2"
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -22,6 +24,7 @@ tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
+107
-9
@@ -1,6 +1,7 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
@@ -100,6 +101,16 @@ pub async fn select_worktree(socket_path: &str, worktree_path: &str) -> Result<V
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn forget_directory(socket_path: &str, worktree_path: &str) -> Result<Value, String> {
|
||||
request(
|
||||
socket_path,
|
||||
"forget_directory",
|
||||
None,
|
||||
Some(json!({ "worktreePath": worktree_path })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_sessions(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
request(socket_path, "list_sessions", Some(agent_id), None).await
|
||||
}
|
||||
@@ -122,22 +133,67 @@ pub async fn new_session(socket_path: &str, agent_id: &str) -> Result<Value, Str
|
||||
request(socket_path, "new_session", Some(agent_id), None).await
|
||||
}
|
||||
|
||||
fn fallback_transcript(state: &Value) -> Value {
|
||||
let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else {
|
||||
return json!({ "data": { "messages": [] } });
|
||||
};
|
||||
let path = Path::new(path);
|
||||
if !path.is_absolute()
|
||||
|| path.extension().and_then(|extension| extension.to_str()) != Some("jsonl")
|
||||
{
|
||||
return json!({ "data": { "messages": [] } });
|
||||
}
|
||||
let messages = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|content| {
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
|
||||
.filter_map(|entry| entry.get("message").cloned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({ "data": { "messages": messages } })
|
||||
}
|
||||
|
||||
fn compact_commands(value: Value) -> Value {
|
||||
let commands = value
|
||||
.pointer("/data/commands")
|
||||
.and_then(Value::as_array)
|
||||
.map(|commands| {
|
||||
commands
|
||||
.iter()
|
||||
.map(|command| {
|
||||
let mut compact = serde_json::Map::new();
|
||||
for field in ["name", "description"] {
|
||||
if let Some(value) = command.get(field) {
|
||||
compact.insert(field.to_owned(), value.clone());
|
||||
}
|
||||
}
|
||||
Value::Object(compact)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({ "data": { "commands": commands } })
|
||||
}
|
||||
|
||||
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||
// Older bridge daemons can still serve the conversation while they await a restart.
|
||||
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
|
||||
.await
|
||||
.unwrap_or_else(|_| json!({ "data": {} }));
|
||||
let (state, transcript, commands, models) = tokio::try_join!(
|
||||
let (state, commands, models) = tokio::try_join!(
|
||||
request(socket_path, "get_state", Some(agent_id), None),
|
||||
request(socket_path, "get_transcript", Some(agent_id), None),
|
||||
request(socket_path, "get_commands", Some(agent_id), None),
|
||||
request(socket_path, "get_available_models", Some(agent_id), None),
|
||||
)?;
|
||||
Ok(json!({
|
||||
"state": state,
|
||||
"stats": stats,
|
||||
"transcript": transcript,
|
||||
"commands": commands,
|
||||
"transcript": fallback_transcript(&state),
|
||||
"commands": compact_commands(commands),
|
||||
"models": models
|
||||
}))
|
||||
}
|
||||
@@ -154,7 +210,9 @@ pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) ->
|
||||
}
|
||||
|
||||
pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> {
|
||||
request(socket_path, "abort", Some(agent_id), None).await.map(|_| ())
|
||||
request(socket_path, "abort", Some(agent_id), None)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn set_model(
|
||||
@@ -303,6 +361,35 @@ mod tests {
|
||||
std::fs::remove_file(path).expect("socket cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() {
|
||||
let path = format!(
|
||||
"{}/pi-status-ui-history-{}.jsonl",
|
||||
env::temp_dir().display(),
|
||||
std::process::id()
|
||||
);
|
||||
std::fs::write(
|
||||
&path,
|
||||
concat!(
|
||||
"{\"type\":\"session\",\"id\":\"session-1\"}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n",
|
||||
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n"
|
||||
),
|
||||
)
|
||||
.expect("session fixture");
|
||||
let transcript = fallback_transcript(&json!({
|
||||
"data": { "sessionFile": path }
|
||||
}));
|
||||
assert_eq!(
|
||||
transcript["data"]["messages"],
|
||||
json!([
|
||||
{ "role": "user", "content": "Hello" },
|
||||
{ "role": "assistant", "content": "Hi" }
|
||||
])
|
||||
);
|
||||
std::fs::remove_file(path).expect("session cleanup");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_state_history_commands_and_models_as_one_snapshot() {
|
||||
let path = format!(
|
||||
@@ -313,7 +400,7 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = UnixListener::bind(&path).expect("listener");
|
||||
let server = tokio::spawn(async move {
|
||||
for _ in 0..5 {
|
||||
for _ in 0..4 {
|
||||
let (stream, _) = listener.accept().await.expect("connection");
|
||||
tokio::spawn(async move {
|
||||
let mut line = String::new();
|
||||
@@ -326,8 +413,11 @@ mod tests {
|
||||
Some("get_session_stats") => json!({
|
||||
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
|
||||
}),
|
||||
Some("get_transcript") => json!({ "data": { "messages": [] } }),
|
||||
Some("get_commands") => json!({ "data": { "commands": [] } }),
|
||||
Some("get_commands") => json!({ "data": { "commands": [{
|
||||
"name": "resume",
|
||||
"description": "Resume a saved session",
|
||||
"sourceInfo": { "path": "/very/large/extension/metadata" }
|
||||
}] } }),
|
||||
Some("get_available_models") => json!({ "data": { "models": [] } }),
|
||||
other => panic!("unexpected operation: {other:?}"),
|
||||
};
|
||||
@@ -336,7 +426,11 @@ mod tests {
|
||||
}))
|
||||
.expect("response JSON");
|
||||
response.push(b'\n');
|
||||
reader.get_mut().write_all(&response).await.expect("response");
|
||||
reader
|
||||
.get_mut()
|
||||
.write_all(&response)
|
||||
.await
|
||||
.expect("response");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -344,6 +438,10 @@ mod tests {
|
||||
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
|
||||
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
|
||||
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
|
||||
assert_eq!(
|
||||
snapshot["commands"]["data"]["commands"],
|
||||
json!([{ "name": "resume", "description": "Resume a saved session" }])
|
||||
);
|
||||
server.await.expect("server succeeds");
|
||||
std::fs::remove_file(path).expect("socket cleanup");
|
||||
}
|
||||
|
||||
+53
-4
@@ -6,6 +6,22 @@ use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
|
||||
|
||||
struct Subscription(Mutex<Option<JoinHandle<()>>>);
|
||||
|
||||
#[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()
|
||||
}
|
||||
@@ -25,6 +41,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
|
||||
@@ -102,7 +123,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 }),
|
||||
@@ -120,17 +143,26 @@ fn subscribe_agent(
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(Subscription(Mutex::new(None)))
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
match window_action(&args, window.is_visible().unwrap_or(false)) {
|
||||
WindowAction::Hide => {
|
||||
let _ = window.hide();
|
||||
}
|
||||
WindowAction::ShowAndFocus => {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_agents,
|
||||
list_directories,
|
||||
select_worktree,
|
||||
forget_directory,
|
||||
load_agent,
|
||||
list_sessions,
|
||||
switch_session,
|
||||
@@ -149,3 +181,20 @@ pub fn run() {
|
||||
eprintln!("Pi Status UI exited: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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(&vec!["--show".to_owned()], true),
|
||||
WindowAction::ShowAndFocus
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
"windows": [
|
||||
{
|
||||
"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,
|
||||
|
||||
+238
-52
@@ -6,16 +6,30 @@
|
||||
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 18px;
|
||||
color: #ebebe8;
|
||||
background: #101111;
|
||||
}
|
||||
* {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 480px;
|
||||
min-height: 420px;
|
||||
min-width: 640px;
|
||||
min-height: 520px;
|
||||
background: transparent;
|
||||
}
|
||||
button,
|
||||
@@ -28,15 +42,22 @@ button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
padding: 8px 12px;
|
||||
padding: 9px 14px;
|
||||
background: #dcecc8;
|
||||
color: #152012;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 750;
|
||||
}
|
||||
button:hover {
|
||||
background: #f0ffe0;
|
||||
}
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 3px solid #c4ed8b;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
position: relative;
|
||||
@@ -46,8 +67,8 @@ button:hover {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid #3b403a;
|
||||
border-radius: 14px;
|
||||
background: rgba(18, 20, 19, 0.97);
|
||||
@@ -70,8 +91,7 @@ button:hover {
|
||||
.workspace-heading,
|
||||
.status-row,
|
||||
.composer-actions,
|
||||
.settings-actions,
|
||||
.folder-form {
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -82,23 +102,23 @@ button:hover {
|
||||
.brand {
|
||||
flex: 0 0 auto;
|
||||
color: #b8df84;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
h1 {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-size: 20px;
|
||||
line-height: 1.15;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-row {
|
||||
margin-top: 3px;
|
||||
margin-top: 4px;
|
||||
color: #898f89;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -120,10 +140,10 @@ h1 {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #404740;
|
||||
border-radius: 999px;
|
||||
padding: 2px 6px;
|
||||
padding: 3px 8px;
|
||||
color: #afb3ad;
|
||||
background: #202320;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.state-pill.working {
|
||||
@@ -148,50 +168,137 @@ h1 {
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.agents {
|
||||
.directory-tabs,
|
||||
.directory-tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
gap: 7px;
|
||||
min-height: 38px;
|
||||
}
|
||||
.directory-tabs {
|
||||
overflow-x: auto;
|
||||
padding: 1px 0 3px;
|
||||
}
|
||||
.directory-tab-list {
|
||||
gap: 3px;
|
||||
}
|
||||
.agent-caption {
|
||||
flex: 0 0 auto;
|
||||
color: #70776f;
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.agent {
|
||||
.directory-tab {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
.directory-tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
padding: 5px 8px;
|
||||
max-width: 220px;
|
||||
border: 1px solid #303530;
|
||||
border-right: 0;
|
||||
border-radius: 7px 0 0 7px;
|
||||
padding: 7px 10px;
|
||||
background: #1b1e1b;
|
||||
color: #aeb4ac;
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
.agent > span {
|
||||
.directory-tab-button > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent small {
|
||||
.directory-tab-button small {
|
||||
color: #70776f;
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.agent.selected {
|
||||
.directory-tab-button.selected {
|
||||
border-color: #7da856;
|
||||
background: #222b1d;
|
||||
color: #ecf7df;
|
||||
}
|
||||
.directory-tab-button.selected + .directory-close {
|
||||
border-color: #7da856;
|
||||
background: #222b1d;
|
||||
}
|
||||
.directory-tab-button:only-child {
|
||||
border-right: 1px solid #303530;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.directory-tab-button.selected:only-child {
|
||||
border-color: #7da856;
|
||||
}
|
||||
.directory-close {
|
||||
min-width: 30px;
|
||||
border: 1px solid #303530;
|
||||
border-radius: 0 7px 7px 0;
|
||||
padding: 4px 7px;
|
||||
background: #1b1e1b;
|
||||
color: #aeb4ac;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.directory-tab:focus-within .directory-tab-button,
|
||||
.directory-tab:focus-within .directory-close {
|
||||
border-color: #c4ed8b;
|
||||
}
|
||||
.directory-close:hover {
|
||||
background: #3a2222;
|
||||
color: #ffc0c0;
|
||||
}
|
||||
.directory-close:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.directory-add-toggle {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.directory-add {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: stretch;
|
||||
gap: 5px;
|
||||
}
|
||||
.directory-add input {
|
||||
width: 280px;
|
||||
padding: 7px 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.directory-add button {
|
||||
padding: 7px 10px;
|
||||
}
|
||||
.directory-add-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #c4ed8b;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.directory-add-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #52703a;
|
||||
border-top-color: #c4ed8b;
|
||||
border-radius: 50%;
|
||||
animation: directory-add-spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes directory-add-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.notifications {
|
||||
display: grid;
|
||||
@@ -227,9 +334,36 @@ h1 {
|
||||
.workflow-main {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 210px;
|
||||
grid-template-columns: minmax(0, 1fr) 250px;
|
||||
gap: 8px;
|
||||
}
|
||||
.transcript-container {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
.transcript-container .transcript {
|
||||
height: 100%;
|
||||
}
|
||||
.scroll-to-latest {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
left: 50%;
|
||||
bottom: 14px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: 1px solid #6f8b56;
|
||||
border-radius: 50%;
|
||||
background: #263020;
|
||||
color: #dcecc8;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.scroll-to-latest:hover {
|
||||
background: #35452b;
|
||||
}
|
||||
.transcript,
|
||||
.settings {
|
||||
min-height: 0;
|
||||
@@ -415,14 +549,14 @@ h1 {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.38;
|
||||
color: #d7dbd5;
|
||||
}
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: #858b85;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.warning {
|
||||
color: #ffb4aa;
|
||||
@@ -435,7 +569,7 @@ h1 {
|
||||
}
|
||||
.pi-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 150px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid #303730;
|
||||
@@ -493,16 +627,16 @@ textarea:focus {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
textarea {
|
||||
min-height: 48px;
|
||||
max-height: 100px;
|
||||
min-height: 64px;
|
||||
max-height: 140px;
|
||||
resize: vertical;
|
||||
font-size: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.composer textarea:focus {
|
||||
outline: none;
|
||||
border-color: #6f8b56;
|
||||
box-shadow: 0 0 0 1px rgba(143, 189, 95, 0.28);
|
||||
outline: 2px solid #c4ed8b;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.composer-actions {
|
||||
justify-content: flex-end;
|
||||
@@ -655,20 +789,49 @@ textarea {
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.folder-form {
|
||||
align-items: stretch;
|
||||
}
|
||||
.remembered {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.settings-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.scale-settings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-top: 1px solid #303830;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.scale-settings-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.scale-settings-heading output {
|
||||
color: #b8df84;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 800;
|
||||
}
|
||||
.scale-adjustment {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.scale-adjustment button {
|
||||
min-width: 32px;
|
||||
padding: 5px 8px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.scale-adjustment button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.scale-adjustment input[type="range"] {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
accent-color: #b8df84;
|
||||
}
|
||||
.session-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -684,6 +847,15 @@ h2 {
|
||||
.session-panel-heading h2 {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.session-actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 5px;
|
||||
}
|
||||
.session-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.session-panel-heading .muted {
|
||||
max-width: 460px;
|
||||
overflow: hidden;
|
||||
@@ -727,9 +899,18 @@ h2 {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.extension {
|
||||
.extension-backdrop {
|
||||
position: absolute;
|
||||
inset: auto 14px 14px;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.extension {
|
||||
width: min(100%, 680px);
|
||||
max-height: min(70vh, 460px);
|
||||
overflow: auto;
|
||||
border: 1px solid #9cc76d;
|
||||
@@ -746,8 +927,13 @@ h2 {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.transcript.working {
|
||||
animation: none;
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,7 +947,7 @@ h2 {
|
||||
grid-template-columns: minmax(0, 1fr) 170px;
|
||||
}
|
||||
.pi-controls {
|
||||
grid-template-columns: minmax(0, 1fr) 120px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.transcript {
|
||||
padding: 7px 9px;
|
||||
|
||||
+604
-173
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user