feat: add multi-session desktop workspace

This commit is contained in:
2026-08-13 21:34:04 +02:00
parent 12b9b83d51
commit 475f95ba70
56 changed files with 8998 additions and 3805 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ All notable changes to Pi Status Bridge are documented here. The project follows
### Changed
- Documentation and repository metadata are being established for public collaboration.
- The bridge now supports durable independently live session runtimes grouped by worktree, with a multi-session desktop workspace.
- Noctalia v4/v5 are aggregate status launchers: they report open, working, attention, recovery, and error counts and toggle the desktop UI without controlling sessions.
## [0.1.0] - 2026-07-27
+3 -5
View File
@@ -2,7 +2,7 @@
> 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.
Pi Status Bridge keeps independently live Pi session runtimes grouped by worktree, exposes a local-only protocol, and provides desktop and Noctalia adapters. It is experimental software: use it on worktrees you control and verify the documented security boundaries before daily use.
## What it provides
@@ -74,12 +74,10 @@ The request payload is the local protocol JSON. See [`DESIGN.md`](DESIGN.md) for
### Noctalia relay
```bash
node src/client/noctalia-relay-cli.js \
--socket "$PI_STATUS_BRIDGE_SOCKET" \
--agent <agent-id>
node src/client/noctalia-relay-cli.js --socket "$PI_STATUS_BRIDGE_SOCKET"
```
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.
The relay and v4/v5 widgets display an aggregate count of open, working, attention-required, recovering, and failed session runtimes. Clicking the widget starts the bridge when needed and toggles the desktop UI; Noctalia never sends prompts, switches sessions, or approves extension requests. `--agent <agent-id>` retains the former agent-scoped relay only for external compatibility callers.
## Keep the bridge running in the background (recommended)
+7 -21
View File
@@ -46,28 +46,14 @@ Prove that the always-running Pi bridge is correctly scoped, recoverable, and us
## Manual Noctalia checklist
### Startup and routing
### Aggregate launcher
- [ ] Starting the bridge creates the home agent and shows it in the bar/popover.
- [ ] Shortcut on a known focused worktree opens that worktree’s agent.
- [ ] Missing or ambiguous focus opens the selector with home preselected.
- [ ] Popover project/session manager switches agents explicitly and visibly.
- [ ] Escape closes the popover without aborting streaming work.
### Status and conversation
- [ ] Bar shows state glyph/color, active project, and attention badge.
- [ ] Popover streams assistant text and tool activity without blocking the composer.
- [ ] Prompt, steer, follow-up, and abort map to the intended Pi action.
- [ ] Queue changes are visible and do not reorder or lose user messages.
- [ ] Pi model/thinking values display correctly and forwarded changes round-trip from Pi.
### Extension prompts
- [ ] Select, confirm, input, and editor requests render as native popover UI.
- [ ] The response reaches the originating extension unchanged.
- [ ] A pending request produces an attention badge while the popover is closed.
- [ ] TUI-only unsupported custom UI is presented as an explicit unsupported/error state, never silently approved.
- [ ] The widget shows aggregate open, working, attention, recovering, and error counts from `get_workspace_summary`.
- [ ] A background session requiring extension attention increases the aggregate attention count.
- [ ] Clicking the widget starts the bridge when needed and toggles the desktop UI.
- [ ] Noctalia contains no composer, session switcher, or extension-response control.
- [ ] The configured `PI_STATUS_UI_BINARY` and fallback binary both launch with `--toggle`.
- [ ] Restarting Noctalia reconnects to the bridge summary without changing active session runtimes.
## Soak and fault scenarios
+23 -7
View File
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Widgets
@@ -16,13 +17,31 @@ Item {
readonly property string screenName: screen?.name ?? ""
readonly property real capsuleHeight: Style.getCapsuleHeightForScreen(screenName)
readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle"
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home"
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "0 sessions"
readonly property int attentionCount: pluginApi?.pluginSettings?.attentionCount ?? 0
readonly property string glyph: bridgeState === "streaming" ? "◒" : bridgeState === "failed" ? "!" : attentionCount > 0 ? "●" : "●"
readonly property color stateColor: bridgeState === "failed" ? Color.mError : attentionCount > 0 ? Color.mPrimary : Color.mOnSurface
readonly property int workingCount: pluginApi?.pluginSettings?.workingCount ?? 0
readonly property int recoveringCount: pluginApi?.pluginSettings?.recoveringCount ?? 0
readonly property int errorCount: pluginApi?.pluginSettings?.errorCount ?? 0
readonly property string glyph: errorCount > 0 ? "!" : bridgeState === "streaming" ? "◒" : recoveringCount > 0 ? "↻" : attentionCount > 0 ? "●" : "●"
readonly property color stateColor: errorCount > 0 ? Color.mError : attentionCount > 0 ? Color.mPrimary : Color.mOnSurface
implicitWidth: content.implicitWidth + Style.marginM * 2
implicitHeight: capsuleHeight
// Keep launch paths configurable for deployments while retaining the local build fallback.
Process {
id: bridgeStartProcess
command: ["systemctl", "--user", "start", "pi-status-bridge.service"]
onExited: function(exitCode) {
if (exitCode === 0)
uiLaunchProcess.running = true
}
}
Process {
id: uiLaunchProcess
command: ["bash", "-c", "ui=\"${PI_STATUS_UI_BINARY:-$HOME/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui}\"; test -x \"$ui\" && exec setsid -f env TMPDIR=/tmp \"$ui\" --toggle >/tmp/pi-status-ui.log 2>&1"]
}
Rectangle {
id: visualCapsule
anchors.centerIn: parent
@@ -46,10 +65,7 @@ Item {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
onClicked: {
if (pluginApi)
pluginApi.togglePanel(root.screen, visualCapsule)
}
onClicked: bridgeStartProcess.running = true
}
}
}
+7 -3
View File
@@ -5,21 +5,25 @@ Item {
id: root
property var pluginApi: null
function setBridgeState(state, projectLabel, attentionCount, detail) {
function setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount) {
if (!pluginApi)
return
pluginApi.pluginSettings.bridgeState = state
pluginApi.pluginSettings.projectLabel = projectLabel
pluginApi.pluginSettings.attentionCount = attentionCount
pluginApi.pluginSettings.detail = detail
pluginApi.pluginSettings.openCount = openCount ?? 0
pluginApi.pluginSettings.workingCount = workingCount ?? 0
pluginApi.pluginSettings.recoveringCount = recoveringCount ?? 0
pluginApi.pluginSettings.errorCount = errorCount ?? 0
pluginApi.saveSettings()
}
IpcHandler {
target: "plugin:pi-status-bridge"
function setBridgeState(state, projectLabel, attentionCount, detail) {
root.setBridgeState(state, projectLabel, attentionCount, detail)
function setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount) {
root.setBridgeState(state, projectLabel, attentionCount, detail, openCount, workingCount, recoveringCount, errorCount)
}
}
}
@@ -1,36 +0,0 @@
import QtQuick
import QtQuick.Layouts
import qs.Commons
import qs.Widgets
Item {
id: root
property var pluginApi: null
readonly property var geometryPlaceholder: panelContainer
readonly property bool allowAttach: true
property real contentPreferredWidth: 680 * Style.uiScaleRatio
property real contentPreferredHeight: 420 * Style.uiScaleRatio
readonly property string bridgeState: pluginApi?.pluginSettings?.bridgeState ?? "idle"
readonly property string projectLabel: pluginApi?.pluginSettings?.projectLabel ?? "Home"
readonly property string detail: pluginApi?.pluginSettings?.detail ?? "Bridge not connected"
anchors.fill: parent
Rectangle {
id: panelContainer
anchors.fill: parent
color: "transparent"
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginL
spacing: Style.marginM
Text { text: "Pi Status Bridge"; color: Color.mOnSurface; font.bold: true; font.pixelSize: 20 }
Text { text: "Project: " + root.projectLabel; color: Color.mOnSurfaceVariant }
Text { text: "State: " + root.bridgeState; color: Color.mOnSurfaceVariant }
Rectangle { Layout.fillWidth: true; height: 1; color: Color.mOutlineVariant }
Text { text: root.detail; color: Color.mOnSurface; wrapMode: Text.Wrap; Layout.fillWidth: true }
Text { text: "Select a project explicitly before sending worktree-scoped actions."; color: Color.mOnSurfaceVariant; wrapMode: Text.Wrap; Layout.fillWidth: true }
Item { Layout.fillHeight: true }
}
}
}
+7 -5
View File
@@ -1,14 +1,16 @@
# Pi Status Bridge Noctalia plugin
This Noctalia v4 plugin is deliberately **presentation-only**. It renders
state supplied through its `plugin:pi-status-bridge` IPC handler and opens its
panel with `pluginApi.togglePanel`.
This Noctalia v4 plugin is an aggregate-status **launcher**. It renders the
workspace counts supplied through its `plugin:pi-status-bridge` IPC handler.
Clicking its bar widget starts `pi-status-bridge.service` and toggles the
standalone Pi Status UI. Set `PI_STATUS_UI_BINARY` to override the UI binary;
the packaged local build path is the fallback.
Use `pi-status-bridge-client` to query the owner-only Unix bridge socket. A
local integration script should translate responses and events into the plugin
IPC handler's `setBridgeState(state, projectLabel, attentionCount, detail)`
call. The plugin never starts Pi, opens TCP, chooses a worktree silently, or
approves extension requests.
call. The plugin never opens a Noctalia panel, sends prompts, switches
sessions, chooses a worktree silently, or approves extension requests.
Install/register it through Noctalia's normal plugin workflow, then add its
bar widget in Noctalia settings.
@@ -1,18 +1,17 @@
{
"id": "pi-status-bridge",
"name": "Pi Status Bridge",
"version": "0.1.0",
"version": "0.2.0",
"minNoctaliaVersion": "4.0.0",
"description": "Presentation-only status bar and panel for a local Pi Status Bridge.",
"description": "Aggregate Pi workspace status and launcher for the standalone Pi Status UI.",
"entryPoints": {
"main": "Main.qml",
"barWidget": "BarWidget.qml",
"panel": "Panel.qml"
"barWidget": "BarWidget.qml"
},
"metadata": {
"defaultSettings": {
"bridgeState": "idle",
"projectLabel": "Home",
"projectLabel": "0 sessions",
"attentionCount": 0,
"detail": "Bridge not connected"
}
+27 -46
View File
@@ -10,6 +10,12 @@ local function ui_binary()
return (noctalia.getenv("HOME") or "") .. "/projects/pi-status-bridge/ui/src-tauri/target/release/pi-status-ui"
end
local function bridge_client_binary()
local configured = noctalia.getenv("PI_STATUS_BRIDGE_CLIENT")
if configured ~= nil and configured ~= "" then return shell_quote(configured) end
return "pi-status-bridge-client"
end
local function socket_path()
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
if runtime == nil or runtime == "" then return nil end
@@ -19,10 +25,6 @@ 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 "
@@ -52,67 +54,46 @@ local function start_bridge(open_after_start)
barWidget.setTooltip("Could not start pi-status-bridge.service")
return
end
if pending_ui_open then
pending_ui_open = false
open_ui()
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
start_bridge(false)
return
local function json_count(payload, key)
local value = string.match(payload, '"' .. key .. '":(%d+)')
return tonumber(value) or 0
end
local home = noctalia.getenv("HOME")
for id, worktree in string.gmatch(result.stdout, "\"id\":\"([^\"]+)\",\"worktreePath\":\"([^\"]+)\"") do
if worktree == home then
noctalia.state.set("agent_id", id)
noctalia.state.set("project_path", worktree)
return
end
end
end)
local function update_summary(payload)
local open = json_count(payload, "openCount")
local working = json_count(payload, "workingCount")
local attention = json_count(payload, "attentionCount")
local recovering = json_count(payload, "recoveringCount")
local errors = json_count(payload, "errorCount")
local glyph = errors > 0 and "alert-triangle" or attention > 0 and "circle-alert" or working > 0 and "terminal-2" or recovering > 0 and "refresh-cw" or "terminal"
local text = errors > 0 and "Pi needs attention" or working > 0 and "Pi working" or recovering > 0 and "Pi recovering" or "Pi ready"
local detail = open .. " open · " .. working .. " working · " .. attention .. " attention · " .. recovering .. " recovering · " .. errors .. " errors"
barWidget.setGlyph(glyph)
barWidget.setText(text)
barWidget.setTooltip(detail .. "; click to open Pi Status UI")
end
function update()
noctalia.setUpdateInterval(2000)
local agent_id = current_agent_id()
local socket = socket_path()
if socket == nil then
start_bridge(false)
return
end
if agent_id == nil then
discover_home_agent(socket)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi home")
barWidget.setTooltip("Starting the default Home agent; click to open Pi Status UI")
return
end
if socket == nil then start_bridge(false); return end
if request_in_flight then return end
request_in_flight = true
local command = "pi-status-bridge-client --socket \"" .. socket
.. "\" request '{\"op\":\"get_state\",\"agentId\":\"" .. agent_id .. "\"}'"
local command = bridge_client_binary() .. " --socket \"" .. socket .. "\" request '{\"op\":\"get_workspace_summary\"}'"
noctalia.runAsync(command, function(result)
request_in_flight = false
if result.exitCode ~= 0 then
noctalia.state.set("agent_id", nil)
start_bridge(false)
barWidget.setGlyph("terminal-2")
barWidget.setText("Pi reconnecting")
barWidget.setTooltip("Refreshing the Home Pi agent")
barWidget.setTooltip("Refreshing Pi workspace summary")
return
end
local streaming = string.find(result.stdout, "\"isStreaming\":true", 1, true) ~= nil
barWidget.setGlyph(streaming and "terminal-2" or "terminal")
barWidget.setText(streaming and "Pi working" or "Pi ready")
barWidget.setTooltip(noctalia.state.get("project_path") or "Selected Pi folder")
update_summary(result.stdout)
end)
end
@@ -1,360 +0,0 @@
local agent_id = nil
local agents = {}
local messages = {}
local folder_path = noctalia.getenv("HOME") or ""
local composer = ""
local extension_input = ""
local pending_extension = nil
local status = "Loading Pi agents…"
local request_in_flight = false
local stream_started = false
local view = "output"
local command_suggestions = {}
local models = {}
local model_options = {}
local thinking_options = { "off", "minimal", "low", "medium", "high", "xhigh", "max" }
local thinking_level = "medium"
local session_summary = "Session loading…"
local function socket_path()
local runtime = noctalia.getenv("XDG_RUNTIME_DIR")
if runtime == nil or runtime == "" then return nil end
return runtime .. "/pi-status-bridge/bridge.sock"
end
local function shell_quote(value)
return "'" .. string.gsub(value, "'", "'\"'\"'") .. "'"
end
local function request(op, agent, payload, callback)
local socket = socket_path()
if socket == nil then
status = "Bridge socket is unavailable."
render_panel()
return
end
local body = { op = op }
if agent ~= nil then body.agentId = agent end
if payload ~= nil then body.payload = payload end
local encoded, err = noctalia.json.encode(body)
if encoded == nil then
status = "Could not encode bridge request: " .. tostring(err)
render_panel()
return
end
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " request " .. shell_quote(encoded)
noctalia.runAsync(command, function(result)
if result.exitCode ~= 0 then
status = "Bridge request failed: " .. noctalia.string.trim(result.stderr)
render_panel()
return
end
local decoded, decode_error = noctalia.json.decode(result.stdout)
if decoded == nil then
status = "Invalid bridge response: " .. tostring(decode_error)
render_panel()
return
end
callback(decoded)
end)
end
local function message_text(message)
if message == nil then return "" end
if type(message.content) == "string" then return message.content end
if type(message.content) ~= "table" then return "" end
local parts = {}
for _, block in ipairs(message.content) do
if block.type == "text" and block.text ~= nil then table.insert(parts, block.text) end
if block.type == "thinking" and block.thinking ~= nil then table.insert(parts, "Thinking: " .. block.thinking) end
if block.type == "toolCall" and block.name ~= nil then table.insert(parts, "Tool: " .. block.name) end
end
return table.concat(parts, "\n")
end
local function choose_home_agent()
local home = noctalia.getenv("HOME")
for _, agent in ipairs(agents) do
if agent.worktreePath == home then return agent.id end
end
return agents[1] and agents[1].id or nil
end
local function known_agent(id)
for _, agent in ipairs(agents) do
if agent.id == id then return true end
end
return false
end
local function refresh_agents()
request("list_agents", nil, nil, function(result)
agents = result.agents or {}
local remembered_id = agent_id or noctalia.state.get("agent_id")
agent_id = known_agent(remembered_id) and remembered_id or choose_home_agent()
if agent_id ~= nil then noctalia.state.set("agent_id", agent_id) end
status = agent_id and "Pi ready" or "Add a folder to start Pi"
render_panel()
if agent_id ~= nil then
refresh_transcript()
refresh_commands()
refresh_controls()
end
end)
end
function refresh_controls()
if agent_id == nil then return end
request("get_state", agent_id, nil, function(result)
local state = result.data or {}
thinking_level = state.thinkingLevel or thinking_level
session_summary = (state.sessionName or state.sessionId or "Session")
.. " • " .. tostring(state.messageCount or 0) .. " messages"
.. " • " .. tostring(state.pendingMessageCount or 0) .. " queued"
status = state.isStreaming and "Pi working" or "Pi ready"
request("get_available_models", agent_id, nil, function(models_result)
models = (models_result.data and models_result.data.models) or {}
model_options = {}
for _, model in ipairs(models) do
table.insert(model_options, (model.provider or "") .. "/" .. (model.id or model.name or "model"))
end
render_panel()
end)
end)
end
function refresh_commands()
if agent_id == nil then return end
request("get_commands", agent_id, nil, function(result)
command_suggestions = {}
for _, command in ipairs((result.data and result.data.commands) or {}) do
if command.name ~= nil then table.insert(command_suggestions, "/" .. command.name) end
end
render_panel()
end)
end
function refresh_transcript()
if agent_id == nil or request_in_flight then return end
request_in_flight = true
request("get_transcript", agent_id, nil, function(result)
request_in_flight = false
messages = result.data and result.data.messages or {}
render_panel()
end)
end
local function start_stream()
if stream_started or agent_id == nil then return end
local socket = socket_path()
if socket == nil then return end
stream_started = true
local command = "pi-status-bridge-client --socket " .. shell_quote(socket) .. " subscribe --agent " .. shell_quote(agent_id)
noctalia.runStream(command, function(line)
local event = noctalia.json.decode(line)
if event == nil then return end
if event.type == "extension_ui_request" then pending_extension = event.data and event.data.event end
if event.type == "agent_state" then status = (event.data and event.data.state) or "Pi state changed" end
if event.type == "queue" then status = "Pi queue updated" end
if event.type == "stream" and event.data and event.data.event and event.data.event.type == "tool_execution_start" then
status = "Tool: " .. tostring(event.data.event.toolName or "running")
end
if event.type == "message_end" or event.type == "agent_state" then refresh_transcript() end
render_panel()
end)
end
local function agent_label(agent)
return agent.worktreePath .. " — " .. agent.state
end
function render_panel()
local selected_path = noctalia.state.get("project_path") or "Home"
local children = {
ui.row({ gap = 8, justify = "space_between" }, {
ui.column({ gap = 2 }, {
ui.label({ text = "Pi", fontSize = 22, fontWeight = "bold" }),
ui.label({ text = selected_path .. " — " .. status })
}),
ui.button({ text = view == "output" and "Settings" or "Output", glyph = "settings", variant = "ghost", onClick = "onToggleSettings" })
})
}
if view == "settings" then
table.insert(children, ui.label({ text = "Folders and agents", fontWeight = "bold" }))
local choices = {}
for _, agent in ipairs(agents) do table.insert(choices, agent_label(agent)) end
if #choices > 0 then table.insert(children, ui.select({ options = choices, onChange = "onSettingsAgentChange" })) end
table.insert(children, ui.input({ value = folder_path, placeholder = "/absolute/path/to/project", onChange = "onFolderChanged", onSubmit = "onAddFolder" }))
table.insert(children, ui.button({ text = "Add folder", glyph = "folder-plus", variant = "primary", onClick = "onAddFolder" }))
table.insert(children, ui.label({ text = "Adding a folder explicitly starts or resumes its own Pi instance." }))
table.insert(children, ui.label({ text = session_summary, maxLines = 2 }))
table.insert(children, ui.label({ text = "Pi controls", fontWeight = "bold" }))
if #model_options > 0 then table.insert(children, ui.select({ options = model_options, onChange = "onModelChange" })) end
table.insert(children, ui.select({ options = thinking_options, onChange = "onThinkingChange" }))
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Retry", onClick = "onRetry" }),
ui.button({ text = "Restart Pi", variant = "destructive", onClick = "onRestart" })
}))
else
table.insert(children, ui.label({ text = "Output", fontWeight = "bold" }))
local transcript = {}
for _, message in ipairs(messages) do
local text = message_text(message)
if text ~= "" then table.insert(transcript, ui.label({ text = (message.role or "message") .. ": " .. text, maxLines = 12 })) end
end
if #transcript == 0 then table.insert(transcript, ui.label({ text = "No messages yet." })) end
table.insert(children, ui.scroll({ minHeight = 260, maxHeight = 520 }, transcript))
table.insert(children, ui.input({ value = composer, placeholder = "Message Pi", onChange = "onComposerChanged", onSubmit = "onPrompt" }))
if string.sub(composer, 1, 1) == "/" and #command_suggestions > 0 then
table.insert(children, ui.select({ options = command_suggestions, onChange = "onCommandSuggestion" }))
end
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Send", variant = "primary", onClick = "onPrompt" }),
ui.button({ text = "Abort", variant = "destructive", onClick = "onAbort" })
}))
end
if pending_extension ~= nil then
local method = pending_extension.method or "input"
table.insert(children, ui.label({ text = "Pi extension request: " .. (pending_extension.title or method), fontWeight = "bold" }))
table.insert(children, ui.label({ text = pending_extension.message or "Choose a response." }))
if method == "select" and pending_extension.options ~= nil then
table.insert(children, ui.select({ options = pending_extension.options, onChange = "onExtensionSelect" }))
elseif method == "confirm" then
table.insert(children, ui.row({ gap = 8 }, {
ui.button({ text = "Confirm", variant = "primary", onClick = "onExtensionConfirm" }),
ui.button({ text = "Decline", onClick = "onExtensionDecline" })
}))
elseif method == "input" or method == "editor" then
table.insert(children, ui.input({ value = extension_input, placeholder = method == "editor" and "Extension editor content" or "Extension response", onChange = "onExtensionInputChanged", onSubmit = "onExtensionSubmit" }))
table.insert(children, ui.button({ text = "Submit", variant = "primary", onClick = "onExtensionSubmit" }))
else
table.insert(children, ui.label({ text = "This extension UI is unsupported in the panel. No response has been approved.", maxLines = 3 }))
end
table.insert(children, ui.button({ text = "Cancel request", variant = "ghost", onClick = "onExtensionCancel" }))
end
panel.render(ui.scroll({ padding = 20, gap = 12 }, children))
end
function onOpen()
panel.setWantsSecondTicks(true)
refresh_agents()
render_panel()
end
function update()
refresh_transcript()
end
function onFolderChanged(value) folder_path = value end
function onComposerChanged(value)
composer = value
if string.sub(composer, 1, 1) == "/" then render_panel() end
end
function onCommandSuggestion(index)
local suggestion = command_suggestions[tonumber(index) + 1]
if suggestion ~= nil then
composer = suggestion .. " "
render_panel()
end
end
function onExtensionInputChanged(value) extension_input = value end
function onSettingsAgentChange(index)
local agent = agents[tonumber(index) + 1]
if agent == nil then return end
agent_id = agent.id
noctalia.state.set("agent_id", agent_id)
noctalia.state.set("project_path", agent.worktreePath)
stream_started = false
start_stream()
refresh_transcript()
end
function onModelChange(index)
local model = models[tonumber(index) + 1]
if model == nil or agent_id == nil then return end
request("set_model", agent_id, { provider = model.provider, modelId = model.id }, function(_) refresh_controls() end)
end
function onThinkingChange(index)
local level = thinking_options[tonumber(index) + 1]
if level == nil or agent_id == nil then return end
request("set_thinking_level", agent_id, { level = level }, function(_) refresh_controls() end)
end
function onRetry()
if agent_id ~= nil then request("retry", agent_id, nil, function(_) refresh_agents() end) end
end
function onRestart()
if agent_id ~= nil then request("restart", agent_id, nil, function(_) refresh_agents() end) end
end
function onAddFolder()
if string.sub(folder_path, 1, 1) ~= "/" then status = "Enter an absolute folder path."; render_panel(); return end
request("select_agent", nil, { worktreePath = folder_path }, function(result)
agent_id = result.agent and result.agent.id or nil
if agent_id == nil then status = "Bridge did not return an agent."; render_panel(); return end
noctalia.state.set("agent_id", agent_id)
noctalia.state.set("project_path", folder_path)
status = "Pi ready for " .. folder_path
stream_started = false
start_stream()
refresh_agents()
end)
end
local function submit_composer()
if agent_id == nil then status = "Choose a folder first."; render_panel(); return end
if composer == "" then return end
request("submit_prompt", agent_id, { message = composer }, function(_)
composer = ""
status = "Prompt queued by Pi"
refresh_transcript()
end)
end
function onPrompt() submit_composer() end
function onAbort() if agent_id ~= nil then request("abort", agent_id, nil, function(_) status = "Aborted"; render_panel() end) end end
local function send_extension_response(response)
if pending_extension == nil or agent_id == nil then return end
request("extension_response", agent_id, { requestId = pending_extension.id, response = response }, function(_)
pending_extension = nil
extension_input = ""
render_panel()
end)
end
function onExtensionSubmit()
send_extension_response({ value = extension_input })
end
function onExtensionSelect(index)
if pending_extension == nil or pending_extension.options == nil then return end
local option = pending_extension.options[tonumber(index) + 1]
if option ~= nil then send_extension_response({ value = option }) end
end
function onExtensionConfirm()
send_extension_response({ confirmed = true })
end
function onExtensionDecline()
send_extension_response({ confirmed = false })
end
function onExtensionCancel()
if pending_extension == nil or agent_id == nil then return end
request("extension_response", agent_id, { requestId = pending_extension.id, response = { cancelled = true } }, function(_)
pending_extension = nil
extension_input = ""
render_panel()
end)
end
function onClosePicker()
panel.close()
end
+2 -2
View File
@@ -24,9 +24,9 @@
"node": ">=20"
},
"scripts": {
"test": "node --test test/*.test.js && npm run test:ui",
"test": "node --test test/*.test.js && npm --prefix ui test && npm run test:ui",
"test:ui": "cargo test --manifest-path ui/src-tauri/Cargo.toml",
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build",
"check": "node --check src/protocol/index.js && node --check src/client/local-client.js && node --check src/client/noctalia-relay.js && node --check src/client/noctalia-ipc.js && node --check src/client/noctalia-relay-cli.js && node --check src/bridge/daemon.js && node --check src/bridge/cli.js && node --check src/bridge/agent-registry.js && node --check src/bridge/workspace-store.js && node --check src/bridge/instance-lock.js && node --check src/bridge/pi-rpc-adapter.js && node --check src/bridge/unix-server.js && node --check src/bridge/runtime.js && node --check src/bridge/recovery-supervisor.js && node --check src/bridge/service.js && npm --prefix ui run build",
"bridge:service:install": "mkdir -p \"$HOME/.config/systemd/user\" && cp systemd/pi-status-bridge.service \"$HOME/.config/systemd/user/\" && systemctl --user daemon-reload && systemctl --user enable --now pi-status-bridge.service",
"bridge:service:start": "systemctl --user start pi-status-bridge.service",
"bridge:service:restart": "systemctl --user restart pi-status-bridge.service",
+99
View File
@@ -0,0 +1,99 @@
# Multi-session workspace architecture
## Status
This is the authoritative contract for the bridge-managed multi-session workspace. It supersedes the one-live-agent-per-worktree assumption for new desktop APIs while preserving the legacy v1 surface.
## Identity
| Identity | Lifetime | Owner | Purpose |
| --- | --- | --- | --- |
| canonical `worktreePath` | durable | filesystem/bridge | directory group |
| `runtimeId` | durable while its tab is desired open | bridge manifest | open-session slot and UI reconciliation |
| Pi `sessionId` + `sessionPath` | durable history | Pi | saved conversation |
| `agentId` | one child in one daemon generation | bridge | legacy command and event routing |
| `bridgeInstanceId` | one daemon generation | bridge | invalidates stale cursors and agent IDs |
The UI never persists `agentId`. One canonical session file has at most one live runtime lease. Opening an already-open session returns its existing runtime.
## Runtime lifecycle
```text
starting -> idle <-> streaming
| |
+-> recovering -> idle | failed
+-> attention/error
any open state -> closing -> stopped
```
Each runtime owns one Pi RPC child, command serialization queue, recovery supervisor, event buffer, extension-attention cache, and summary. Several runtimes may share a worktree and session directory, but never a session file. Same-worktree concurrency is allowed and directory summaries expose open/working counts so clients can warn about filesystem and Git collisions.
Commands and lifecycle mutations are serialized per runtime. Close removes desired-open intent before stopping the child. A working runtime is aborted first, then stopped with a bounded wait and forced termination fallback. Closing keeps the JSONL history. Forgetting a directory is refused while any of its runtimes remain open.
## Persistence ownership
The bridge stores `bridge-workspace-v2.json` beneath the owner-only persistent `sessionRoot`. It is versioned, mode `0600`, and replaced atomically through a temporary file and rename. It records desired-open runtime IDs, canonical worktrees, and Pi session identity. Daemon shutdown preserves records; explicit close removes one.
On the first v2 migration only the legacy Home runtime is opened. After that migration an explicitly closed Home runtime remains closed and zero live Pi runtimes is valid. Restore launches desired-open runtimes with concurrency two. Six or more open runtimes produces a resource warning but there is no hard cap.
Missing, moved, corrupt, cross-directory, or duplicate session records are never replaced with a new session. They are retained/reported as failed dormant runtime intent or as a manifest issue. A corrupt whole manifest is reported and starts no runtimes rather than guessing.
The Tauri UI owns presentation persistence (order, selection, drafts, scroll and last-seen state) in owner-only app data. It must not persist transcripts, extension response values, request IDs, or arbitrary extension payloads.
Legacy `bridge-agent.json` remains the compatibility default-session pointer. New runtime state is authoritative in the workspace manifest.
## Additive v1 compatibility API
Existing `list_agents`, `select_agent`, `list_sessions`, `new_session`, `switch_session`, agent commands, and agent-scoped `subscribe` retain their meanings. `select_agent` resolves a compatible default runtime for that directory. The desktop must not implement tabs with legacy session switching.
New operations:
- `get_workspace`
- `get_workspace_summary`
- `create_session_runtime`
- `open_session_runtime`
- `close_session_runtime`
- `list_directory_sessions`
- `get_session_runtime_snapshot`
- `subscribe_workspace`
All cursors are non-negative integers.
## Workspace event and replay contract
`subscribe_workspace` is the preferred aggregate subscription. Every event has a global order and contains:
```json
{
"version": "v1",
"bridgeInstanceId": "daemon UUID",
"seq": 42,
"type": "runtime_event",
"agentId": "ephemeral when live",
"data": {
"runtimeId": "durable slot UUID",
"worktreePath": "/canonical/path",
"sessionId": "Pi UUID when known",
"eventType": "tool",
"eventData": {}
}
}
```
Replay responses include `bridgeInstanceId`, `firstAvailableSeq`, `latestSeq`, `truncated`, and `events`. Clients reload the workspace and runtime snapshots when the daemon ID changes or replay is truncated. Listener installation precedes replay delivery, preserving replay-then-live ordering.
## Snapshot and summary contract
`get_session_runtime_snapshot` addresses one `runtimeId` and returns runtime summary, state, transcript, statistics, commands, models, cached actionable extension requests, `bridgeInstanceId`, and the latest global sequence. A failed dormant runtime returns its identity/error summary without silently starting another conversation.
Runtime labels prioritize explicit Pi session name, first user prompt, then `New session`. Runtime summaries cache state, queue count, active tool, attention, error/recovery state and last activity. Workspace/directory summaries aggregate open, working, recovering, attention and error counts; Noctalia consumes only the aggregate summary and launches the desktop UI.
## Safety and recovery
- The bridge remains the sole Pi process/session/recovery owner.
- Each runtime recovery restarts with its exact leased session path.
- Session identity is refreshed at launch, after legacy session mutation, and after authoritative idle/settled transitions so the first prompt-created file is persisted.
- Background runtimes continue receiving events and extension attention.
- Pending extension requests are scoped to a runtime; another tab cannot answer them.
- A daemon restart changes `bridgeInstanceId` and all `agentId` values while keeping `runtimeId` stable.
- Restore failures surface explicitly. The bridge never substitutes a different session.
+1177 -331
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -4,6 +4,7 @@ import path from "node:path";
import { StringDecoder } from "node:string_decoder";
export const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
export const COMPACT_COMMAND_TIMEOUT_MS = 5 * 60_000;
export const MAX_PI_RPC_FRAME_BYTES = 1024 * 1024;
const supportedCommands = new Set([
@@ -18,6 +19,8 @@ const supportedCommands = new Set([
"get_messages",
"get_available_models",
"get_commands",
"set_session_name",
"compact",
"set_model",
"set_thinking_level",
]);
@@ -126,6 +129,7 @@ export function startPiRpcAdapter({
onEvent = () => {},
onError = () => {},
commandTimeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
compactCommandTimeoutMs = COMPACT_COMMAND_TIMEOUT_MS,
maxFrameBytes = MAX_PI_RPC_FRAME_BYTES,
}) {
assertAbsolutePath(cwd, "cwd");
@@ -316,6 +320,10 @@ export function startPiRpcAdapter({
}
const id = `bridge-${randomUUID()}`;
const timeoutMs =
commandInput.type === "compact"
? compactCommandTimeoutMs
: commandTimeoutMs;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id);
@@ -325,7 +333,7 @@ export function startPiRpcAdapter({
`Pi RPC command timed out: ${commandInput.type}`,
),
);
}, commandTimeoutMs);
}, timeoutMs);
timeout.unref?.();
pending.set(id, { resolve, reject, timeout });
try {
+52 -1
View File
@@ -1,5 +1,9 @@
import path from "node:path";
import { createAgentRegistry, UnknownAgentError } from "./agent-registry.js";
import {
createAgentRegistry,
UnknownAgentError,
UnknownRuntimeError,
} from "./agent-registry.js";
import { startPiRpcAdapter } from "./pi-rpc-adapter.js";
import { ProtocolError } from "../protocol/index.js";
import { createRuntimePaths, startBridgeServer } from "./runtime.js";
@@ -32,6 +36,35 @@ export async function startBridgeService({
};
case "forget_directory":
return registry.forgetDirectory(request.payload.worktreePath);
case "get_workspace":
return registry.getWorkspace();
case "get_workspace_summary":
return registry.getWorkspaceSummary();
case "create_session_runtime":
return {
runtime: await registry.createSessionRuntime(
request.payload.worktreePath,
),
};
case "open_session_runtime":
return {
runtime: await registry.openSessionRuntime(
request.payload.worktreePath,
request.payload.sessionPath,
),
};
case "close_session_runtime":
return registry.closeSessionRuntime(request.payload.runtimeId);
case "list_directory_sessions":
return {
sessions: await registry.listDirectorySessions(
request.payload.worktreePath,
),
};
case "get_session_runtime_snapshot":
return registry.getSessionRuntimeSnapshot(request.payload.runtimeId);
case "subscribe_workspace":
return registry.workspaceEventsAfter(request.payload.cursor ?? 0);
case "subscribe":
return {
events: registry.eventsAfter(
@@ -51,6 +84,8 @@ export async function startBridgeService({
} catch (error) {
if (error instanceof UnknownAgentError)
throw new ProtocolError("unknown_agent", error.message);
if (error instanceof UnknownRuntimeError)
throw new ProtocolError("unknown_runtime", error.message);
throw error;
}
};
@@ -59,6 +94,22 @@ export async function startBridgeService({
runtimeDir,
handleRequest: dispatch,
subscribe: (request, notify) => {
if (request.op === "subscribe_workspace") {
const subscription = registry.subscribeWorkspace(
request.payload.cursor ?? 0,
notify,
);
return {
result: {
bridgeInstanceId: subscription.bridgeInstanceId,
firstAvailableSeq: subscription.firstAvailableSeq,
latestSeq: subscription.latestSeq,
truncated: subscription.truncated,
events: subscription.events,
},
unsubscribe: subscription.unsubscribe,
};
}
const subscription = registry.subscribe(
request.agentId,
request.payload.cursor ?? 0,
+4 -1
View File
@@ -88,7 +88,10 @@ export async function startUnixSocketServer({
const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
try {
const request = parseRequestFrame(normalized, maxFrameBytes);
if (request.op === "subscribe" && subscribe) {
if (
(request.op === "subscribe" || request.op === "subscribe_workspace") &&
subscribe
) {
await handleSubscription(request);
return;
}
+153
View File
@@ -0,0 +1,153 @@
import {
chmod,
mkdir,
readFile,
rename,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
const WORKSPACE_VERSION = 2;
const WORKSPACE_FILE = "bridge-workspace-v2.json";
const DIRECTORY_MODE = 0o700;
const FILE_MODE = 0o600;
function validRuntime(record) {
return (
record &&
typeof record === "object" &&
typeof record.runtimeId === "string" &&
record.runtimeId.length > 0 &&
typeof record.worktreePath === "string" &&
path.isAbsolute(record.worktreePath) &&
(record.sessionPath === undefined ||
(typeof record.sessionPath === "string" &&
path.isAbsolute(record.sessionPath))) &&
(record.sessionId === undefined || typeof record.sessionId === "string") &&
(record.legacyDefault === undefined ||
typeof record.legacyDefault === "boolean")
);
}
function normalizeManifest(value) {
if (
!value ||
typeof value !== "object" ||
value.version !== WORKSPACE_VERSION ||
!Array.isArray(value.runtimes) ||
!value.runtimes.every(validRuntime) ||
(value.directories !== undefined &&
(!Array.isArray(value.directories) ||
!value.directories.every(
(directory) =>
typeof directory === "string" && path.isAbsolute(directory),
)))
)
throw new Error("workspace manifest has an unsupported or invalid shape");
const runtimeIds = new Set();
const sessionPaths = new Set();
const directories = new Set(value.directories ?? []);
if (directories.size !== (value.directories ?? []).length)
throw new Error("workspace manifest repeats a directory");
for (const runtime of value.runtimes) directories.add(runtime.worktreePath);
for (const runtime of value.runtimes) {
if (runtimeIds.has(runtime.runtimeId))
throw new Error(
`workspace manifest repeats runtime ${runtime.runtimeId}`,
);
runtimeIds.add(runtime.runtimeId);
if (runtime.sessionPath) {
if (sessionPaths.has(runtime.sessionPath))
throw new Error(
`workspace manifest repeats session ${runtime.sessionPath}`,
);
sessionPaths.add(runtime.sessionPath);
}
}
return {
version: WORKSPACE_VERSION,
migrated: value.migrated === true,
directories: [...directories].sort(),
runtimes: value.runtimes.map((runtime) => ({ ...runtime })),
};
}
export function createWorkspaceStore(sessionRoot) {
if (typeof sessionRoot !== "string" || !path.isAbsolute(sessionRoot))
throw new TypeError("sessionRoot must be an absolute path");
const filePath = path.join(sessionRoot, WORKSPACE_FILE);
let writeQueue = Promise.resolve();
async function load() {
await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE });
await chmod(sessionRoot, DIRECTORY_MODE);
try {
return {
manifest: normalizeManifest(
JSON.parse(await readFile(filePath, "utf8")),
),
migrating: false,
};
} catch (error) {
if (error?.code === "ENOENT") {
return {
manifest: {
version: WORKSPACE_VERSION,
migrated: false,
directories: [],
runtimes: [],
},
migrating: true,
};
}
return {
manifest: {
version: WORKSPACE_VERSION,
migrated: true,
directories: [],
runtimes: [],
},
migrating: false,
issue: {
code: "invalid_workspace_manifest",
message: error instanceof Error ? error.message : String(error),
},
};
}
}
function save(manifest) {
const normalized = normalizeManifest({ ...manifest, migrated: true });
const operation = writeQueue
.catch(() => {})
.then(async () => {
await mkdir(sessionRoot, { recursive: true, mode: DIRECTORY_MODE });
await chmod(sessionRoot, DIRECTORY_MODE);
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
try {
await writeFile(
temporaryPath,
`${JSON.stringify(normalized, null, 2)}\n`,
{
encoding: "utf8",
mode: FILE_MODE,
},
);
await chmod(temporaryPath, FILE_MODE);
await rename(temporaryPath, filePath);
await chmod(filePath, FILE_MODE);
} finally {
await rm(temporaryPath, { force: true }).catch(() => {});
}
});
writeQueue = operation.catch(() => {});
return operation;
}
return { filePath, load, save };
}
export const workspaceManifestVersion = WORKSPACE_VERSION;
export const workspaceManifestFile = WORKSPACE_FILE;
+8 -21
View File
@@ -7,29 +7,16 @@ export async function publishNoctaliaState(
state,
{ execute = async (command, args) => execFileAsync(command, args) } = {},
) {
if (!state || typeof state !== "object")
throw new TypeError("state is required");
const { state: bridgeState, projectLabel, attentionCount, detail } = state;
if (!state || typeof state !== "object") throw new TypeError("state is required");
const { state: bridgeState, projectLabel, attentionCount, detail, openCount = 0, workingCount = 0, recoveringCount = 0, errorCount = 0 } = state;
if (
typeof bridgeState !== "string" ||
typeof projectLabel !== "string" ||
!Number.isSafeInteger(attentionCount) ||
typeof bridgeState !== "string" || typeof projectLabel !== "string" ||
![attentionCount, openCount, workingCount, recoveringCount, errorCount].every(Number.isSafeInteger) ||
typeof detail !== "string"
) {
throw new TypeError(
"state must contain string state/projectLabel/detail and integer attentionCount",
);
}
) throw new TypeError("state must contain presentation strings and integer counts");
await execute("qs", [
"-c",
"noctalia-shell",
"ipc",
"call",
"plugin:pi-status-bridge",
"setBridgeState",
bridgeState,
projectLabel,
String(attentionCount),
detail,
"-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState",
bridgeState, projectLabel, String(attentionCount), detail, String(openCount),
String(workingCount), String(recoveringCount), String(errorCount),
]);
}
+11 -15
View File
@@ -10,25 +10,23 @@ function flag(args, name) {
async function run() {
const args = process.argv.slice(2);
const socketPath =
flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
const socketPath = flag(args, "--socket") ?? process.env.PI_STATUS_BRIDGE_SOCKET;
const agentId = flag(args, "--agent");
if (!socketPath || !agentId)
throw new Error(
"Usage: pi-status-bridge-noctalia-relay --socket <path> --agent <id>",
);
if (!socketPath)
throw new Error("Usage: pi-status-bridge-noctalia-relay --socket <path> [--agent <id>]");
const client = await connectLocalClient({ socketPath });
let agent;
if (agentId) {
const agents = await client.request("list_agents");
const agent = agents.agents.find((candidate) => candidate.id === agentId);
agent = agents.agents.find((candidate) => candidate.id === agentId);
if (!agent) throw new Error(`unknown agent: ${agentId}`);
}
const relay = createNoctaliaStateRelay({
client,
agent,
onState: (state) => {
void publishNoctaliaState(state).catch((error) =>
...(agent ? { agent } : {}),
onState: (state) => void publishNoctaliaState(state).catch((error) =>
process.stderr.write(`${error.message}\n`),
);
},
),
});
await relay.start();
await new Promise((resolve) => {
@@ -41,8 +39,6 @@ async function run() {
}
run().catch((error) => {
process.stderr.write(
`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`,
);
process.stderr.write(`${error instanceof Error ? error.message : "Noctalia relay failed"}\n`);
process.exitCode = 1;
});
+87 -43
View File
@@ -1,58 +1,102 @@
import path from "node:path";
function detailFor(state) {
switch (state) {
case "streaming":
return "Streaming";
case "recovering":
return "Recovering Pi session";
case "failed":
return "Recovery failed";
case "error":
return "Bridge error";
default:
return "Idle";
}
function detailForSummary(summary) {
return [
`${summary.openCount} open`,
`${summary.workingCount} working`,
`${summary.attentionCount} attention`,
`${summary.recoveringCount} recovering`,
`${summary.errorCount} errors`,
].join(" · ");
}
export function createNoctaliaStateRelay({ client, agent, onState }) {
if (
!client ||
typeof client.request !== "function" ||
typeof client.subscribe !== "function"
)
throw new TypeError("client must support request and subscribe");
if (!agent?.id || !agent?.worktreePath)
throw new TypeError("agent id and worktreePath are required");
if (typeof onState !== "function")
throw new TypeError("onState must be a function");
function stateForSummary(summary) {
if (summary.errorCount) return "error";
if (summary.recoveringCount) return "recovering";
if (summary.workingCount) return "streaming";
return "idle";
}
let state = "idle";
let attentionCount = 0;
let unsubscribe = () => {};
const projectLabel = path.basename(agent.worktreePath) || agent.worktreePath;
const publish = () =>
onState({ state, projectLabel, attentionCount, detail: detailFor(state) });
const handleEvent = (event) => {
if (event.type === "agent_state" && typeof event.data?.state === "string")
state = event.data.state;
if (event.type === "queue")
attentionCount =
(event.data?.event?.steering?.length ?? 0) +
(event.data?.event?.followUp?.length ?? 0);
if (event.type === "extension_ui_request")
attentionCount = Math.max(attentionCount, 1);
publish();
function summaryState(summary) {
return {
state: stateForSummary(summary),
projectLabel: `${summary.openCount} session${summary.openCount === 1 ? "" : "s"}`,
attentionCount: summary.attentionCount,
detail: detailForSummary(summary),
openCount: summary.openCount,
workingCount: summary.workingCount,
recoveringCount: summary.recoveringCount,
errorCount: summary.errorCount,
};
}
function legacyDetail(state) {
switch (state) {
case "streaming": return "Streaming";
case "recovering": return "Recovering Pi session";
case "failed": return "Recovery failed";
case "error": return "Bridge error";
default: return "Idle";
}
}
/**
* Relays workspace-wide presentation state to Noctalia. Passing `agent` keeps
* the former agent-scoped behavior for external compatibility callers only.
*/
export function createNoctaliaStateRelay({ client, agent, onState, pollMs = 2_000 }) {
if (!client || typeof client.request !== "function")
throw new TypeError("client must support request");
if (agent && (!agent.id || !agent.worktreePath))
throw new TypeError("agent id and worktreePath are required");
if (!agent && (!Number.isSafeInteger(pollMs) || pollMs < 250))
throw new TypeError("pollMs must be an integer of at least 250ms");
if (typeof onState !== "function") throw new TypeError("onState must be a function");
let unsubscribe = () => {};
let timer;
let stopped = false;
const publishLegacy = (state, attentionCount) => onState({
state,
projectLabel: path.basename(agent.worktreePath) || agent.worktreePath,
attentionCount,
detail: legacyDetail(state),
openCount: 1,
workingCount: state === "streaming" ? 1 : 0,
recoveringCount: state === "recovering" ? 1 : 0,
errorCount: ["error", "failed"].includes(state) ? 1 : 0,
});
return {
async start() {
if (agent) {
if (typeof client.subscribe !== "function")
throw new TypeError("legacy agent relay requires client.subscribe");
const response = await client.request("get_state", { agentId: agent.id });
state = response?.data?.isStreaming ? "streaming" : "idle";
publish();
unsubscribe = await client.subscribe(agent.id, 0, handleEvent);
let state = response?.data?.isStreaming ? "streaming" : "idle";
let attentionCount = 0;
publishLegacy(state, attentionCount);
unsubscribe = await client.subscribe(agent.id, 0, (event) => {
if (event.type === "agent_state" && typeof event.data?.state === "string") state = event.data.state;
if (event.type === "queue") attentionCount = (event.data?.event?.steering?.length ?? 0) + (event.data?.event?.followUp?.length ?? 0);
if (event.type === "extension_ui_request") attentionCount = Math.max(attentionCount, 1);
publishLegacy(state, attentionCount);
});
return;
}
const refresh = async () => {
try {
const summary = await client.request("get_workspace_summary");
if (!stopped) onState(summaryState(summary));
} finally {
if (!stopped) timer = setTimeout(() => void refresh(), pollMs);
}
};
await refresh();
},
stop() {
stopped = true;
clearTimeout(timer);
unsubscribe();
unsubscribe = () => {};
},
+74 -7
View File
@@ -8,6 +8,14 @@ const requestOperations = new Map([
["list_directories", { agent: false, payload: "none" }],
["select_agent", { agent: false, payload: "worktree" }],
["forget_directory", { agent: false, payload: "worktree" }],
["get_workspace", { agent: false, payload: "none" }],
["get_workspace_summary", { agent: false, payload: "none" }],
["create_session_runtime", { agent: false, payload: "worktree" }],
["open_session_runtime", { agent: false, payload: "worktreeSession" }],
["close_session_runtime", { agent: false, payload: "runtime" }],
["list_directory_sessions", { agent: false, payload: "worktree" }],
["get_session_runtime_snapshot", { agent: false, payload: "runtime" }],
["subscribe_workspace", { agent: false, payload: "cursor" }],
["get_state", { agent: true, payload: "none" }],
["get_session_stats", { agent: true, payload: "none" }],
["list_sessions", { agent: true, payload: "none" }],
@@ -25,6 +33,8 @@ const requestOperations = new Map([
["restart", { agent: true, payload: "none" }],
["get_available_models", { agent: true, payload: "none" }],
["get_commands", { agent: true, payload: "none" }],
["set_session_name", { agent: true, payload: "sessionName" }],
["compact", { agent: true, payload: "compact" }],
["set_model", { agent: true, payload: "model" }],
["set_thinking_level", { agent: true, payload: "thinking" }],
]);
@@ -100,10 +110,10 @@ function assertString(value, field, { maxLength = 4096, pattern } = {}) {
function assertOptionalCursor(value) {
if (value === undefined) return undefined;
if (Number.isSafeInteger(value) && value >= 0) return value;
return assertString(value, "payload.cursor", {
maxLength: 256,
pattern: idPattern,
});
throw new ProtocolError(
"invalid_message",
"payload.cursor must be a non-negative integer",
);
}
function validatePayload(kind, value) {
@@ -115,6 +125,7 @@ function validatePayload(kind, value) {
);
return undefined;
}
if (kind === "compact" && value === undefined) return undefined;
const payload = assertRecord(value, "payload");
switch (kind) {
@@ -137,9 +148,7 @@ function validatePayload(kind, value) {
const sessionPath = assertString(
payload.sessionPath,
"payload.sessionPath",
{
maxLength: 4096,
},
{ maxLength: 4096 },
);
if (!path.isAbsolute(sessionPath))
throw new ProtocolError(
@@ -148,6 +157,38 @@ function validatePayload(kind, value) {
);
return { sessionPath };
}
case "worktreeSession": {
assertAllowedKeys(
payload,
new Set(["worktreePath", "sessionPath"]),
"payload",
);
const worktreePath = assertString(
payload.worktreePath,
"payload.worktreePath",
{ maxLength: 4096 },
);
const sessionPath = assertString(
payload.sessionPath,
"payload.sessionPath",
{ maxLength: 4096 },
);
if (!path.isAbsolute(worktreePath) || !path.isAbsolute(sessionPath))
throw new ProtocolError(
"invalid_message",
"payload worktree and session paths must be absolute",
);
return { worktreePath, sessionPath };
}
case "runtime": {
assertAllowedKeys(payload, new Set(["runtimeId"]), "payload");
return {
runtimeId: assertString(payload.runtimeId, "payload.runtimeId", {
maxLength: 128,
pattern: idPattern,
}),
};
}
case "cursor": {
assertAllowedKeys(payload, new Set(["cursor"]), "payload");
return {
@@ -185,6 +226,32 @@ function validatePayload(kind, value) {
response,
};
}
case "sessionName": {
assertAllowedKeys(payload, new Set(["name"]), "payload");
const name = assertString(payload.name, "payload.name", {
maxLength: 4096,
}).trim();
if (!name)
throw new ProtocolError(
"invalid_message",
"payload.name must not be blank",
);
return { name };
}
case "compact": {
assertAllowedKeys(payload, new Set(["customInstructions"]), "payload");
const customInstructions = assertString(
payload.customInstructions,
"payload.customInstructions",
{ maxLength: 32 * 1024 },
).trim();
if (!customInstructions)
throw new ProtocolError(
"invalid_message",
"payload.customInstructions must not be blank",
);
return { customInstructions };
}
case "model": {
assertAllowedKeys(payload, new Set(["provider", "modelId"]), "payload");
return {
+12 -3
View File
@@ -1,16 +1,25 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp } from "node:fs/promises";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createAgentRegistry } from "../src/bridge/agent-registry.js";
import {
createAgentRegistry,
sessionDirectoryPath,
} from "../src/bridge/agent-registry.js";
test("recovers an exited agent with its last Pi session reference", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-recovery-"));
const home = join(root, "home");
const sessionRoot = join(root, "sessions");
await mkdir(home);
const sessionFile = join(sessionRoot, "pi-session.jsonl");
const sessionDir = sessionDirectoryPath(sessionRoot, home);
await mkdir(sessionDir, { recursive: true });
const sessionFile = join(sessionDir, "pi-session.jsonl");
await writeFile(
sessionFile,
`${JSON.stringify({ type: "session", version: 3, id: "recovery", cwd: home })}\n`,
);
const calls = [];
const registry = createAgentRegistry({
homeWorktree: home,
+43 -15
View File
@@ -134,6 +134,10 @@ test("drains a forget still resolving its canonical path during shutdown", async
await stop();
};
const selectedFeature = registry
.listAgents()
.find((agent) => agent.worktreePath === worktrees.feature);
await registry.closeSessionRuntime(selectedFeature.runtimeId);
const forgetting = registry.forgetDirectory(featureAlias);
const stopping = registry.stop();
await Promise.all([forgetting, stopping]);
@@ -170,6 +174,11 @@ test("forgets a directory without deleting its resumable session history", async
`${JSON.stringify({ sessionPath, worktreePath: worktrees.feature })}\n`,
);
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
await registry.closeSessionRuntime(feature.runtimeId);
await registry.forgetDirectory(worktrees.feature);
assert.equal(fixture.calls[1].adapter.stopped, true);
@@ -227,10 +236,14 @@ test("coordinates forgetting with directory creation and active commands", async
const selecting = registry.selectWorktree(worktrees.feature);
await featureStarted;
const forgettingDuringCreation = registry.forgetDirectory(worktrees.feature);
releaseFeatureCreation();
await selecting;
await forgettingDuringCreation;
const selectedDuringCreation = await selecting;
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
await registry.closeSessionRuntime(selectedDuringCreation.runtimeId);
await registry.forgetDirectory(worktrees.feature);
assert.equal(fixture.calls[1].adapter.stopped, true);
assert.equal(
(await registry.listDirectories()).some(
@@ -252,15 +265,18 @@ test("coordinates forgetting with directory creation and active commands", async
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/,
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
const closing = registry.closeSessionRuntime(
registry.listAgents().find((agent) => agent.worktreePath === worktrees.feature)
.runtimeId,
);
await new Promise((resolve) => setImmediate(resolve));
releaseStop();
await Promise.all([firstForget, secondForget]);
await closing;
await registry.forgetDirectory(worktrees.feature);
assert.equal(stopCalls, 1);
assert.equal(registry.listAgents().length, 1);
@@ -276,15 +292,15 @@ test("coordinates forgetting with directory creation and active commands", async
const prompt = registry.route(feature.id, "prompt", {
message: "Keep working",
});
const forgettingDuringPrompt = registry.forgetDirectory(worktrees.feature);
await new Promise((resolve) => setImmediate(resolve));
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/runtimes are open/,
);
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.closeSessionRuntime(feature.runtimeId);
await registry.forgetDirectory(worktrees.feature);
assert.equal(adapter.stopped, true);
@@ -307,14 +323,19 @@ test("coordinates forgetting with directory creation and active commands", async
});
rejectPrompt(new Error("send failed"));
await failedPromptAssertion;
assert.equal(
registry.listAgents().find((entry) => entry.id === failedFeature.id).state,
"streaming",
);
await assert.rejects(
registry.forgetDirectory(worktrees.feature),
/while Pi is working/,
/runtimes are open/,
);
failedFixture.options.onEvent({
type: "agent_state",
data: { state: "idle" },
});
await registry.closeSessionRuntime(failedFeature.runtimeId);
await registry.forgetDirectory(worktrees.feature);
await registry.stop();
});
@@ -389,6 +410,7 @@ test("catalogues managed directories and exposes only their sessions", async ()
}),
/ENOENT|belong/,
);
await registry.closeSessionRuntime(feature.runtimeId);
await registry.stop();
const restored = createAgentRegistry({
@@ -444,11 +466,17 @@ test("routes commands by explicit agent ID and replays only events after the cur
command: "get_commands",
success: true,
});
await registry.route(agent.id, "set_session_name", { name: "Release UI" });
await registry.route(agent.id, "compact", {
customInstructions: "Preserve implementation details",
});
assert.deepEqual(adapter.sent, [
{ type: "get_state" },
{ type: "prompt", message: "Stay in this worktree" },
{ type: "get_session_stats" },
{ type: "get_commands" },
{ type: "set_session_name", name: "Release UI" },
{ type: "compact", customInstructions: "Preserve implementation details" },
]);
options.onEvent({
+62 -1
View File
@@ -18,11 +18,16 @@ function connect(socketPath) {
function request(socket, frame) {
return new Promise((resolve, reject) => {
let buffer = "";
const onError = (error) => {
socket.off("data", onData);
reject(error);
};
const onData = (chunk) => {
buffer += chunk;
const newline = buffer.indexOf("\n");
if (newline === -1) return;
socket.off("data", onData);
socket.off("error", onError);
try {
resolve(JSON.parse(buffer.slice(0, newline)));
} catch (error) {
@@ -30,7 +35,7 @@ function request(socket, frame) {
}
};
socket.on("data", onData);
socket.once("error", reject);
socket.once("error", onError);
socket.write(`${JSON.stringify(frame)}\n`);
});
}
@@ -89,6 +94,32 @@ test("starts the home agent and dispatches local protocol requests to the select
payload: { worktreePath: feature },
});
const featureAgent = selected.result.agent;
const workspace = await request(socket, {
version: "v1",
id: "workspace-1",
op: "get_workspace",
});
const summary = await request(socket, {
version: "v1",
id: "summary-1",
op: "get_workspace_summary",
});
assert.equal(typeof workspace.result.bridgeInstanceId, "string");
assert.equal(workspace.result.directories.length, 2);
assert.equal(summary.result.openCount, 2);
assert.equal(summary.result.directoryCount, 2);
assert.equal(summary.result.resourceWarning, false);
const snapshot = await request(socket, {
version: "v1",
id: "snapshot-1",
op: "get_session_runtime_snapshot",
payload: { runtimeId: featureAgent.runtimeId },
});
assert.equal(snapshot.result.runtime.runtimeId, featureAgent.runtimeId);
assert.equal(
snapshot.result.bridgeInstanceId,
workspace.result.bridgeInstanceId,
);
const prompt = await request(socket, {
version: "v1",
id: "prompt-1",
@@ -98,18 +129,48 @@ test("starts the home agent and dispatches local protocol requests to the select
});
assert.equal(prompt.ok, true);
const named = await request(socket, {
version: "v1",
id: "name-1",
op: "set_session_name",
agentId: featureAgent.id,
payload: { name: "Feature work" },
});
const compacted = await request(socket, {
version: "v1",
id: "compact-1",
op: "compact",
agentId: featureAgent.id,
payload: { customInstructions: "Preserve tests" },
});
assert.equal(named.ok, true);
assert.equal(compacted.ok, true);
assert.deepEqual(
fixture.calls.map(({ options }) => options.cwd),
[home, feature],
);
assert.deepEqual(fixture.calls[1].adapter.sent, [
{ type: "get_state" },
{ type: "get_state" },
{ type: "get_session_stats" },
{ type: "get_messages" },
{ type: "get_commands" },
{ type: "get_available_models" },
{ type: "prompt", message: "Use this worktree" },
{ type: "set_session_name", name: "Feature work" },
{ type: "compact", customInstructions: "Preserve tests" },
]);
fixture.calls[1].options.onEvent({
type: "agent_state",
data: { state: "idle" },
});
const closed = await request(socket, {
version: "v1",
id: "close-1",
op: "close_session_runtime",
payload: { runtimeId: featureAgent.runtimeId },
});
assert.equal(closed.ok, true);
const forgotten = await request(socket, {
version: "v1",
+14 -54
View File
@@ -10,64 +10,24 @@ test("starts a bridge daemon around the local bridge service", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-daemon-"));
const home = join(root, "home");
await mkdir(home);
const daemon = await startBridgeDaemon({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: () => ({
send: async () => ({ type: "response", success: true }),
respondToExtension: () => {},
stop: async () => {},
}),
});
try {
assert.match(daemon.socketPath, /bridge\.sock$/);
assert.equal(daemon.listAgents()[0].worktreePath, home);
} finally {
await daemon.close();
}
const daemon = await startBridgeDaemon({ homeWorktree: home, runtimeDir: join(root, "runtime"), sessionRoot: join(root, "sessions"), startAdapter: () => ({ send: async () => ({ type: "response", success: true }), respondToExtension: () => {}, stop: async () => {} }) });
try { assert.match(daemon.socketPath, /bridge\.sock$/); assert.equal(daemon.listAgents()[0].worktreePath, home); }
finally { await daemon.close(); }
});
test("relays only the selected agent's bridge state into a presentation update", async () => {
let listener;
test("relays aggregate workspace summary as compact Noctalia state", async () => {
const updates = [];
const relay = createNoctaliaStateRelay({
client: {
request: async () => ({ data: { isStreaming: false } }),
subscribe: async (_agentId, _cursor, callback) => {
listener = callback;
return () => {};
},
},
agent: { id: "agent-1", worktreePath: "/worktrees/feature" },
onState: (state) => updates.push(state),
client: { request: async (operation) => { assert.equal(operation, "get_workspace_summary"); return { openCount: 3, workingCount: 1, attentionCount: 2, recoveringCount: 0, errorCount: 1 }; } },
onState: (state) => updates.push(state), pollMs: 250,
});
await relay.start(); relay.stop();
assert.deepEqual(updates, [{ state: "error", projectLabel: "3 sessions", attentionCount: 2, detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors", openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1 }]);
});
await relay.start();
listener({ type: "agent_state", data: { state: "streaming" } });
listener({
type: "queue",
data: { event: { steering: ["focus"], followUp: [] } },
});
assert.deepEqual(updates, [
{
state: "idle",
projectLabel: "feature",
attentionCount: 0,
detail: "Idle",
},
{
state: "streaming",
projectLabel: "feature",
attentionCount: 0,
detail: "Streaming",
},
{
state: "streaming",
projectLabel: "feature",
attentionCount: 1,
detail: "Streaming",
},
]);
test("retains agent-scoped relay behavior only when explicitly requested", async () => {
let listener; const updates = [];
const relay = createNoctaliaStateRelay({ client: { request: async () => ({ data: { isStreaming: false } }), subscribe: async (_agentId, _cursor, callback) => { listener = callback; return () => {}; } }, agent: { id: "agent-1", worktreePath: "/worktrees/feature" }, onState: (state) => updates.push(state) });
await relay.start(); listener({ type: "agent_state", data: { state: "streaming" } });
assert.equal(updates.at(-1).state, "streaming"); assert.equal(updates.at(-1).projectLabel, "feature");
});
File diff suppressed because it is too large Load Diff
+7 -22
View File
@@ -2,33 +2,18 @@ import assert from "node:assert/strict";
import test from "node:test";
import { publishNoctaliaState } from "../src/client/noctalia-ipc.js";
test("publishes presentation state through Noctalia's documented IPC command", async () => {
test("publishes aggregate presentation state through Noctalia's documented IPC command", async () => {
const calls = [];
await publishNoctaliaState(
{
state: "streaming",
projectLabel: "feature",
attentionCount: 2,
detail: "Streaming",
state: "streaming", projectLabel: "3 sessions", attentionCount: 2,
detail: "3 open · 1 working · 2 attention · 0 recovering · 1 errors",
openCount: 3, workingCount: 1, recoveringCount: 0, errorCount: 1,
},
{ execute: async (command, args) => calls.push({ command, args }) },
);
assert.deepEqual(calls, [
{
assert.deepEqual(calls, [{
command: "qs",
args: [
"-c",
"noctalia-shell",
"ipc",
"call",
"plugin:pi-status-bridge",
"setBridgeState",
"streaming",
"feature",
"2",
"Streaming",
],
},
]);
args: ["-c", "noctalia-shell", "ipc", "call", "plugin:pi-status-bridge", "setBridgeState", "streaming", "3 sessions", "2", "3 open · 1 working · 2 attention · 0 recovering · 1 errors", "3", "1", "0", "1"],
}]);
});
+13 -6
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("ships a Noctalia plugin with compact bar and presentation-only panel entry points", async () => {
test("ships a Noctalia aggregate launcher without a primary panel", async () => {
const manifestText = await readFile(
"noctalia-plugin/pi-status-bridge/manifest.json",
"utf8",
@@ -15,9 +15,8 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
error instanceof Error ? error.message : "manifest was not valid JSON",
);
}
const [bar, panel, main] = await Promise.all([
const [bar, main] = await Promise.all([
readFile("noctalia-plugin/pi-status-bridge/BarWidget.qml", "utf8"),
readFile("noctalia-plugin/pi-status-bridge/Panel.qml", "utf8"),
readFile("noctalia-plugin/pi-status-bridge/Main.qml", "utf8"),
]);
@@ -25,10 +24,18 @@ test("ships a Noctalia plugin with compact bar and presentation-only panel entry
assert.deepEqual(manifest.entryPoints, {
main: "Main.qml",
barWidget: "BarWidget.qml",
panel: "Panel.qml",
});
assert.match(bar, /pluginApi\.togglePanel\(root\.screen, visualCapsule\)/);
assert.match(panel, /allowAttach: true/);
assert.match(bar, /systemctl", "--user", "start", "pi-status-bridge\.service/);
assert.match(bar, /PI_STATUS_UI_BINARY/);
assert.match(bar, /pi-status-ui/);
assert.match(bar, /setsid -f env TMPDIR=\/tmp/);
assert.match(bar, /--toggle/);
assert.doesNotMatch(bar, /togglePanel/);
assert.doesNotMatch(bar, /Panel\.qml/);
assert.match(main, /IpcHandler/);
assert.match(main, /setBridgeState/);
assert.match(main, /openCount/);
assert.match(main, /workingCount/);
assert.match(main, /recoveringCount/);
assert.match(main, /errorCount/);
});
+14 -3
View File
@@ -1,8 +1,8 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { access, readFile } from "node:fs/promises";
import test from "node:test";
test("ships a v5 Noctalia compatibility launcher instead of a primary panel", async () => {
test("ships a v5 Noctalia aggregate launcher instead of a primary panel", async () => {
const [manifest, bridge] = await Promise.all([
readFile("noctalia-v5-plugin/pi-status-bridge/plugin.toml", "utf8"),
readFile("noctalia-v5-plugin/pi-status-bridge/bridge.luau", "utf8"),
@@ -19,10 +19,21 @@ test("ships a v5 Noctalia compatibility launcher instead of a primary panel", as
assert.doesNotMatch(bridge, /--show/);
assert.match(bridge, /pi-status-ui\.log/);
assert.doesNotMatch(bridge, /togglePanel/);
assert.match(bridge, /get_workspace_summary/);
assert.match(bridge, /openCount/);
assert.match(bridge, /workingCount/);
assert.match(bridge, /attentionCount/);
assert.match(bridge, /recoveringCount/);
assert.match(bridge, /errorCount/);
assert.match(bridge, /Pi reconnecting/);
assert.match(bridge, /discover_home_agent\(socket\)/);
assert.doesNotMatch(bridge, /discover_home_agent/);
assert.doesNotMatch(bridge, /get_state/);
assert.match(bridge, /systemctl --user start pi-status-bridge\.service/);
assert.match(bridge, /bridge_start_in_flight/);
assert.match(bridge, /pending_ui_open/);
assert.match(bridge, /Bridge is starting/);
await assert.rejects(
access("noctalia-v5-plugin/pi-status-bridge/picker.luau"),
{ code: "ENOENT" },
);
});
+34
View File
@@ -115,6 +115,40 @@ test("requests Pi session statistics for context and token status", async () =>
await adapter.stop();
});
test("forwards native session naming and compaction commands", async () => {
const fixture = createFixture();
const adapter = startPiRpcAdapter({
cwd: "/workspace/home",
sessionDir: "/workspace/sessions",
spawnProcess: fixture.spawn,
});
const named = adapter.send({ type: "set_session_name", name: "Release UI" });
const [nameCommand] = fixture.sent();
assert.equal(nameCommand.type, "set_session_name");
assert.equal(nameCommand.name, "Release UI");
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: nameCommand.id, command: "set_session_name", success: true })}\n`,
);
await named;
const compacted = adapter.send({
type: "compact",
customInstructions: "Preserve implementation details",
});
const [, compactCommand] = fixture.sent();
assert.equal(compactCommand.type, "compact");
assert.equal(
compactCommand.customInstructions,
"Preserve implementation details",
);
fixture.child.stdout.write(
`${JSON.stringify({ type: "response", id: compactCommand.id, command: "compact", success: true })}\n`,
);
await compacted;
await adapter.stop();
});
test("forwards extension responses without replacing Pi's request ID", async () => {
const fixture = createFixture();
const events = [];
+92
View File
@@ -108,6 +108,98 @@ test("accepts bridge-managed directory and session operations", () => {
);
});
test("accepts additive multi-session runtime operations and numeric workspace cursors", () => {
const worktreePath = "/home/alex/project";
const sessionPath = "/home/alex/project/sessions/one.jsonl";
for (const value of [
{ op: "get_workspace" },
{ op: "get_workspace_summary" },
{ op: "create_session_runtime", payload: { worktreePath } },
{ op: "open_session_runtime", payload: { worktreePath, sessionPath } },
{ op: "close_session_runtime", payload: { runtimeId: "runtime-1" } },
{ op: "list_directory_sessions", payload: { worktreePath } },
{
op: "get_session_runtime_snapshot",
payload: { runtimeId: "runtime-1" },
},
{ op: "subscribe_workspace", payload: { cursor: 42 } },
])
assert.equal(parseRequestFrame(request(value)).op, value.op);
assert.throws(
() =>
parseRequestFrame(
request({
op: "subscribe_workspace",
payload: { cursor: "opaque" },
}),
),
/cursor must be a non-negative integer/,
);
});
test("accepts native session naming and manual compaction requests", () => {
assert.deepEqual(
parseRequestFrame(
request({
op: "set_session_name",
agentId: "agent-main",
payload: { name: "Release UI" },
}),
),
{
version: "v1",
id: "request-1",
op: "set_session_name",
agentId: "agent-main",
payload: { name: "Release UI" },
},
);
assert.deepEqual(
parseRequestFrame(request({ op: "compact", agentId: "agent-main" })),
{
version: "v1",
id: "request-1",
op: "compact",
agentId: "agent-main",
},
);
assert.deepEqual(
parseRequestFrame(
request({
op: "compact",
agentId: "agent-main",
payload: { customInstructions: "Preserve implementation details" },
}),
),
{
version: "v1",
id: "request-1",
op: "compact",
agentId: "agent-main",
payload: { customInstructions: "Preserve implementation details" },
},
);
for (const invalidRequest of [
request({
op: "set_session_name",
agentId: "agent-main",
payload: { name: " " },
}),
request({
op: "set_session_name",
agentId: "agent-main",
payload: { name: "Valid", extra: true },
}),
request({
op: "compact",
agentId: "agent-main",
payload: { customInstructions: " " },
}),
request({ op: "compact", agentId: "agent-main", payload: { extra: true } }),
])
assert.throws(() => parseRequestFrame(invalidRequest), ProtocolError);
});
test("accepts an agent session-statistics request", () => {
assert.deepEqual(
parseRequestFrame(
+50
View File
@@ -42,6 +42,56 @@ function collectFrames(socket, count) {
});
}
test("workspace subscription exposes daemon identity, replay boundaries, and globally ordered live events", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-workspace-subscribe-"));
const home = join(root, "home");
await mkdir(home);
const calls = [];
const service = await startBridgeService({
homeWorktree: home,
runtimeDir: join(root, "runtime"),
sessionRoot: join(root, "sessions"),
startAdapter: (options) => {
const adapter = {
send: async () => ({ type: "response", success: true, data: {} }),
respondToExtension: () => {},
stop: async () => {},
};
calls.push({ options, adapter });
return adapter;
},
});
const socket = await connect(service.socketPath);
try {
const frames = collectFrames(socket, 2);
socket.write(
`${JSON.stringify({ version: "v1", id: "workspace-1", op: "subscribe_workspace", payload: { cursor: 0 } })}\n`,
);
setTimeout(() => {
calls[0].options.onEvent({
type: "stream",
data: { event: { type: "message_update", delta: "live" } },
});
}, 10);
const [replay, live] = await frames;
assert.equal(replay.ok, true);
assert.equal(typeof replay.result.bridgeInstanceId, "string");
assert.equal(replay.result.truncated, false);
assert.equal(Number.isSafeInteger(replay.result.firstAvailableSeq), true);
assert.equal(Number.isSafeInteger(replay.result.latestSeq), true);
assert.ok(replay.result.events.length >= 1);
assert.equal(live.type, "event");
assert.equal(live.event.bridgeInstanceId, replay.result.bridgeInstanceId);
assert.equal(live.event.type, "runtime_event");
assert.equal(live.event.data.eventType, "stream");
assert.ok(live.event.seq > replay.result.events.at(-1).seq);
} finally {
socket.destroy();
await service.close();
}
});
test("replays then streams subscribed agent events on the same local socket", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-status-bridge-subscribe-"));
const home = join(root, "home");
+66 -367
View File
@@ -2,385 +2,84 @@ import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("renders every Pi RPC extension UI method without replying to fire-and-forget requests", async () => {
const source = await readFile("ui/src/App.tsx", "utf8");
for (const method of [
"select",
"confirm",
"input",
"editor",
"notify",
"setStatus",
"setWidget",
"setTitle",
"set_editor_text",
]) {
assert.match(source, new RegExp(`case "${method}"`));
}
assert.match(source, /setPendingExtension\(extension\)/);
assert.doesNotMatch(source, /This extension UI is unsupported/);
assert.match(source, /function stripAnsi/);
assert.match(source, /stripAnsi\(extension\.statusText\)/);
assert.match(source, /className="pi-controls"/);
assert.match(source, /aria-label="Model and thinking controls"/);
assert.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,
/event\.ctrlKey && event\.key\.toLowerCase\(\) === "c"/,
);
assert.match(source, /getCurrentWindow\(\)\s*\.hide\(\)\s*\.catch/);
assert.match(
source,
/window\.addEventListener\("keydown", handleKeydown, true\)/,
);
assert.match(source, /useLayoutEffect/);
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
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/);
assert.match(source, /\.toFixed\(1\)/);
assert.match(source, /formatTokens\(stats\.tokens\.input\)/);
assert.match(source, /formatTokens\(stats\.tokens\.output\)/);
assert.match(source, /formatTokens\(cacheTokens\)/);
assert.match(source, /function updateWorkProgress/);
assert.match(
source,
/className=\{`work-progress composer-progress \$\{workProgress\.phase\}`\}/,
);
assert.match(source, /event\.type === "tool"/);
assert.match(source, /event\.type === "queue"/);
assert.match(source, /composer-progress/);
assert.doesNotMatch(
source,
/<section className=\{`work-progress \$\{workProgress\.phase\}`\}/,
);
});
test("exposes accessible directory, session, notification, and extension controls", async () => {
const [component, styles] = await Promise.all([
const sources = Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/workspace/reducer.ts", "utf8"),
readFile("ui/src/workspace/useWorkspace.ts", "utf8"),
readFile("ui/src/workspace/persistence.ts", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
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("wires the desktop to stable runtime workspace APIs", async () => {
const [app, reducer, hook] = await sources;
for (const operation of [
"create_session_runtime",
"open_session_runtime",
"close_session_runtime",
"list_directory_sessions",
"get_session_runtime_snapshot",
"subscribe_workspace",
])
assert.match(`${app}\n${hook}`, new RegExp(operation));
assert.doesNotMatch(app, /switch_session/);
assert.doesNotMatch(app, /new_session/);
assert.match(reducer, /WorkspaceState\["runtimesById"\]/);
assert.match(reducer, /requestGeneration/);
assert.match(reducer, /loadedThroughSeq/);
});
test("makes the window draggable from the non-interactive header", async () => {
const [component, styles, capability] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
test("keeps presentation persistence allowlisted and agent IDs ephemeral", async () => {
const [, , , persistence] = await sources;
assert.match(persistence, /load_ui_state/);
assert.match(persistence, /save_ui_state/);
assert.match(persistence, /directoryOrder/);
assert.match(persistence, /drafts/);
assert.doesNotMatch(persistence, /agentId/);
assert.doesNotMatch(persistence, /messages/);
assert.doesNotMatch(persistence, /extensions/);
});
test("renders directory sidebar, accessible session tabs, warnings, and reduced motion", async () => {
const [app, , , , css] = await sources;
const [sidebar, tabs] = await Promise.all([
readFile("ui/src/components/DirectorySidebar.tsx", "utf8"),
readFile("ui/src/components/SessionTabs.tsx", "utf8"),
]);
assert.match(component, /startDragging\(\)/);
assert.match(
component,
/closest\(\s*"button, input, select, textarea",?\s*\)/,
);
assert.match(styles, /\.workflow-header \{[\s\S]*cursor: grab/);
assert.match(capability, /core:window:allow-start-dragging/);
assert.match(sidebar, /aria-label="Directories"/);
assert.match(tabs, /role="tablist"/);
assert.match(tabs, /role="tab"/);
assert.match(tabs, /aria-selected/);
assert.match(tabs, /ArrowRight/);
assert.match(app, /Multiple sessions are open in the same directory/);
assert.match(app, /Many Pi sessions are open/);
assert.match(css, /prefers-reduced-motion/);
assert.match(css, /grid-template-columns:\s*178px minmax\(0, 1fr\)/);
});
test("opens local follow-up choices for /model and /resume", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
test("preserves native session naming, compaction, composer, and extension responses", async () => {
const [app] = await sources;
const [conversation, extension] = await Promise.all([
readFile("ui/src/components/ConversationWorkspace.tsx", "utf8"),
readFile("ui/src/components/ExtensionDialog.tsx", "utf8"),
]);
assert.match(component, /const controlCommands: Command\[\]/);
assert.match(component, /followUp: "model"/);
assert.match(component, /followUp: "resume"/);
assert.match(component, /followUp: "thinking"/);
assert.match(component, /const nativeCommand = controlCommands\.find/);
assert.match(component, /function chooseThinking/);
assert.match(component, /function openCommandFollowUp/);
assert.match(component, /function chooseModel/);
assert.match(component, /className="command-followup"/);
assert.match(component, /New session/);
assert.match(styles, /\.command-followup \{/);
assert.match(styles, /\.command-choice-list \{/);
assert.match(app, /set_session_name/);
assert.match(app, /compact/);
assert.match(app, /submit_prompt/);
assert.match(app, /respond_to_extension/);
assert.match(conversation, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(conversation, /className="todos-pane"/);
assert.match(extension, /role="dialog"/);
assert.match(extension, /aria-modal="true"/);
});
test("shows bridge-managed directory status and sessions for the opened directory", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(
component,
/invoke<\{ directories: Directory\[\] \}>\("list_directories"\)/,
);
assert.match(
component,
/invoke<\{ sessions: Session\[\] \}>\("list_sessions"/,
);
assert.match(component, /function chooseDirectory/);
assert.match(component, /function 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 \{/);
});
test("renders todo-plugin state and compact context/token status", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /function currentTodos/);
assert.match(component, /message\.toolName === "todo"/);
assert.match(component, /className="todos-pane"/);
assert.match(component, /className="workflow-main"/);
assert.match(component, /ctx \{formatTokens\(contextTokens\)\}/);
assert.match(component, /tok \{formatTokens\(stats\.tokens\.total\)\}/);
assert.match(styles, /\.workflow-main \{/);
assert.match(styles, /\.todos-pane \{/);
assert.match(styles, /\.status-metric \{/);
});
test("shows an optimistic prompt until the transcript receives it", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /type PendingSubmission/);
assert.match(
component,
/const \[pendingSubmissions, setPendingSubmissions\]/,
);
assert.match(component, /setStatus\("Sending prompt to Pi…"\)/);
assert.match(component, /phase: "sending"/);
assert.match(component, /phase: "sent"/);
assert.match(component, /Sent · waiting for Pi/);
assert.match(component, /receivedMessages\.some/);
assert.match(component, /pending-message/);
assert.match(styles, /\.pending-message \{/);
assert.match(styles, /\.pending-message\.sending/);
});
test("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 \{/);
assert.match(styles, /background: #192536/);
assert.match(styles, /\.message\.assistant \{/);
assert.match(styles, /background: #1a241a/);
});
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.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 () => {
const source = await readFile("ui/src/App.tsx", "utf8");
assert.match(source, /onKeyDown=\{\(event\) => \{/);
assert.match(source, /event\.key !== "Enter"/);
assert.match(source, /event\.shiftKey/);
assert.match(source, /event\.nativeEvent\.isComposing/);
assert.match(source, /event\.preventDefault\(\);\s*void submit\(\);/);
assert.match(source, /Enter to send · Shift\+Enter for newline/);
});
test("uses a compact workflow layout that keeps the composer and transcript stable", async () => {
const [component, styles] = await Promise.all([
readFile("ui/src/App.tsx", "utf8"),
readFile("ui/src/App.css", "utf8"),
]);
assert.match(component, /className="workflow-header"/);
assert.match(component, /className="workflow"/);
assert.match(component, /className="workflow-footer"/);
assert.match(component, /className="composer-stack"/);
assert.match(
styles,
/\.workflow \{[\s\S]*grid-template-rows: minmax\(0, 1fr\) auto/,
);
assert.match(styles, /\.transcript,\s*\.settings \{[\s\S]*overflow: auto/);
assert.match(
component,
/className=\{`transcript \$\{pendingExtension \? "with-extension" : ""\} \$\{workProgress\.phase\}`\}/,
);
assert.match(
styles,
/\.transcript\.with-extension \{[\s\S]*padding-bottom: 112px/,
);
assert.match(styles, /\.transcript\.working \{/);
assert.match(styles, /conic-gradient\(/);
assert.match(styles, /transcript-border-orbit/);
assert.match(styles, /prefers-reduced-motion: reduce/);
assert.match(styles, /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\) 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([
test("configures native window behavior and folder dialog capabilities", async () => {
const [config, capability, cargo] = await Promise.all([
readFile("ui/src-tauri/tauri.conf.json", "utf8"),
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
readFile("ui/src-tauri/src/lib.rs", "utf8"),
readFile("ui/src-tauri/Cargo.toml", "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\(\)\)/);
assert.match(config, /"width"\s*:\s*1100/);
assert.match(capability, /core:window:allow-start-dragging/);
assert.match(cargo, /tauri-plugin-single-instance/);
assert.match(cargo, /tauri-plugin-dialog/);
});
+85
View File
@@ -0,0 +1,85 @@
import assert from "node:assert/strict";
import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createWorkspaceStore } from "../src/bridge/workspace-store.js";
test("persists an owner-only versioned workspace manifest atomically", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-store-"));
const store = createWorkspaceStore(join(root, "sessions"));
const first = await store.load();
assert.equal(first.migrating, true);
await store.save({
version: 2,
migrated: true,
runtimes: [
{
runtimeId: "runtime-1",
worktreePath: join(root, "home"),
sessionPath: join(root, "sessions", "agent", "one.jsonl"),
sessionId: "session-1",
},
],
});
const metadata = await stat(store.filePath);
assert.equal(metadata.mode & 0o777, 0o600);
const restored = (await store.load()).manifest;
assert.equal(restored.runtimes[0].runtimeId, "runtime-1");
assert.deepEqual(restored.directories, [join(root, "home")]);
assert.equal((await readFile(store.filePath, "utf8")).endsWith("\n"), true);
});
test("accepts old manifests and persists managed empty directories", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-directories-"));
const store = createWorkspaceStore(join(root, "sessions"));
await store.save({
version: 2,
migrated: true,
directories: [join(root, "empty")],
runtimes: [],
});
assert.deepEqual((await store.load()).manifest.directories, [
join(root, "empty"),
]);
await writeFile(
store.filePath,
JSON.stringify({ version: 2, migrated: true, runtimes: [] }),
{ mode: 0o600 },
);
assert.deepEqual((await store.load()).manifest.directories, []);
});
test("reports a corrupt manifest without treating it as first migration", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-corrupt-"));
const store = createWorkspaceStore(join(root, "sessions"));
await store.save({ version: 2, migrated: true, runtimes: [] });
await writeFile(store.filePath, "{broken", { mode: 0o600 });
const loaded = await store.load();
assert.equal(loaded.migrating, false);
assert.equal(loaded.manifest.runtimes.length, 0);
assert.equal(loaded.issue.code, "invalid_workspace_manifest");
});
test("recovers its serialized write queue after a failed save", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-workspace-retry-"));
const sessionRoot = join(root, "sessions");
const store = createWorkspaceStore(sessionRoot);
await mkdir(sessionRoot);
await mkdir(store.filePath);
await assert.rejects(
store.save({ version: 2, migrated: true, runtimes: [] }),
);
await rm(store.filePath, { recursive: true });
await store.save({ version: 2, migrated: true, runtimes: [] });
assert.deepEqual((await store.load()).manifest.runtimes, []);
});
+1217 -1
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -12,6 +12,8 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview",
"tauri": "tauri",
"tauri:release": "tauri build --no-bundle"
@@ -25,10 +27,15 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"jsdom": "^26.1.0",
"typescript": "~5.8.3",
"vite": "^7.0.4"
"vite": "^7.0.4",
"vitest": "^3.2.7"
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ tauri-build = { version = "2", features = [] }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
tokio = { version = "1", features = ["io-util", "net", "rt", "macros", "time"] }
tauri-plugin-dialog = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+858 -1
View File
@@ -1,10 +1,12 @@
use serde::Serialize;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::{json, Value};
use std::env;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::time::{sleep, Duration};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
@@ -83,6 +85,181 @@ pub async fn request(
result_from_response(value)
}
async fn typed_request<T: DeserializeOwned>(
socket_path: &str,
operation: &str,
payload: Option<Value>,
) -> Result<T, String> {
let value = request(socket_path, operation, None, payload).await?;
serde_json::from_value(value)
.map_err(|error| format!("Bridge returned an invalid {operation} result: {error}"))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSummary {
pub runtime_id: String,
pub worktree_path: String,
pub state: String,
pub label: String,
pub attention: bool,
pub queue_count: u64,
pub last_activity: String,
pub opened_at: String,
pub active_tool: Option<String>,
pub agent_id: Option<String>,
pub session_id: Option<String>,
pub session_path: Option<String>,
pub error: Option<Value>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryWorkspace {
pub worktree_path: String,
pub is_home: bool,
pub open_count: u64,
pub working_count: u64,
pub attention_count: u64,
pub recovering_count: u64,
pub error_count: u64,
pub runtimes: Vec<RuntimeSummary>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Workspace {
pub bridge_instance_id: String,
pub latest_seq: u64,
pub directories: Vec<DirectoryWorkspace>,
pub issue: Option<Value>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceSummary {
pub bridge_instance_id: String,
pub latest_seq: u64,
pub open_count: u64,
pub working_count: u64,
pub attention_count: u64,
pub recovering_count: u64,
pub error_count: u64,
pub directory_count: u64,
pub resource_warning: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RuntimeResult {
pub runtime: RuntimeSummary,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CloseRuntimeResult {
pub runtime_id: String,
pub session_path: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectorySession {
pub path: String,
pub id: String,
pub cwd: String,
pub name: Option<String>,
pub parent_session_path: Option<String>,
pub created: Option<String>,
pub modified: String,
pub message_count: u64,
pub first_message: Option<String>,
pub is_current: bool,
pub runtime_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DirectorySessionsResult {
pub sessions: Vec<DirectorySession>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSnapshot {
pub bridge_instance_id: String,
pub latest_seq: u64,
pub runtime: RuntimeSummary,
pub state: Option<Value>,
pub stats: Option<Value>,
pub transcript: Option<Value>,
pub commands: Option<Value>,
pub models: Option<Value>,
#[serde(default)]
pub extensions: Vec<Value>,
}
pub async fn get_workspace(socket_path: &str) -> Result<Workspace, String> {
typed_request(socket_path, "get_workspace", None).await
}
pub async fn get_workspace_summary(socket_path: &str) -> Result<WorkspaceSummary, String> {
typed_request(socket_path, "get_workspace_summary", None).await
}
pub async fn create_session_runtime(
socket_path: &str,
worktree_path: &str,
) -> Result<RuntimeResult, String> {
typed_request(
socket_path,
"create_session_runtime",
Some(json!({ "worktreePath": worktree_path })),
)
.await
}
pub async fn open_session_runtime(
socket_path: &str,
worktree_path: &str,
session_path: &str,
) -> Result<RuntimeResult, String> {
typed_request(
socket_path,
"open_session_runtime",
Some(json!({ "worktreePath": worktree_path, "sessionPath": session_path })),
)
.await
}
pub async fn close_session_runtime(
socket_path: &str,
runtime_id: &str,
) -> Result<CloseRuntimeResult, String> {
typed_request(
socket_path,
"close_session_runtime",
Some(json!({ "runtimeId": runtime_id })),
)
.await
}
pub async fn list_directory_sessions(
socket_path: &str,
worktree_path: &str,
) -> Result<DirectorySessionsResult, String> {
typed_request(
socket_path,
"list_directory_sessions",
Some(json!({ "worktreePath": worktree_path })),
)
.await
}
pub async fn get_session_runtime_snapshot(
socket_path: &str,
runtime_id: &str,
) -> Result<RuntimeSnapshot, String> {
typed_request(
socket_path,
"get_session_runtime_snapshot",
Some(json!({ "runtimeId": runtime_id })),
)
.await
}
pub async fn list_agents(socket_path: &str) -> Result<Value, String> {
request(socket_path, "list_agents", None, None).await
}
@@ -246,6 +423,32 @@ pub async fn set_thinking_level(
.map(|_| ())
}
pub async fn set_session_name(socket_path: &str, agent_id: &str, name: &str) -> Result<(), String> {
request(
socket_path,
"set_session_name",
Some(agent_id),
Some(json!({ "name": name })),
)
.await
.map(|_| ())
}
pub async fn compact(
socket_path: &str,
agent_id: &str,
custom_instructions: Option<&str>,
) -> Result<(), String> {
request(
socket_path,
"compact",
Some(agent_id),
custom_instructions.map(|instructions| json!({ "customInstructions": instructions })),
)
.await
.map(|_| ())
}
pub async fn command(socket_path: &str, agent_id: &str, operation: &str) -> Result<(), String> {
request(socket_path, operation, Some(agent_id), None)
.await
@@ -268,6 +471,303 @@ pub async fn respond_to_extension(
.map(|_| ())
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceEvent {
pub bridge_instance_id: String,
pub seq: u64,
#[serde(flatten)]
pub body: serde_json::Map<String, Value>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct WorkspaceReplay {
bridge_instance_id: String,
first_available_seq: u64,
latest_seq: u64,
truncated: bool,
events: Vec<WorkspaceEvent>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(
tag = "kind",
rename_all = "camelCase",
rename_all_fields = "camelCase"
)]
pub enum WorkspaceHostEvent {
Connected {
bridge_instance_id: String,
first_available_seq: u64,
latest_seq: u64,
},
Event {
event: WorkspaceEvent,
},
ResetRequired {
bridge_instance_id: String,
latest_seq: u64,
reason: String,
},
Disconnected {
message: String,
retry_in_ms: u64,
},
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SubscriptionCursor {
pub bridge_instance_id: Option<String>,
pub seq: u64,
}
fn accept_replay(
cursor: &mut SubscriptionCursor,
replay: &WorkspaceReplay,
) -> Vec<WorkspaceHostEvent> {
if let Some(expected) = &cursor.bridge_instance_id {
if expected != &replay.bridge_instance_id {
cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone());
cursor.seq = replay.latest_seq;
return vec![WorkspaceHostEvent::ResetRequired {
bridge_instance_id: replay.bridge_instance_id.clone(),
latest_seq: replay.latest_seq,
reason: "epochChanged".into(),
}];
}
}
if replay.truncated {
cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone());
cursor.seq = replay.latest_seq;
return vec![WorkspaceHostEvent::ResetRequired {
bridge_instance_id: replay.bridge_instance_id.clone(),
latest_seq: replay.latest_seq,
reason: "replayTruncated".into(),
}];
}
cursor.bridge_instance_id = Some(replay.bridge_instance_id.clone());
let mut output = vec![WorkspaceHostEvent::Connected {
bridge_instance_id: replay.bridge_instance_id.clone(),
first_available_seq: replay.first_available_seq,
latest_seq: replay.latest_seq,
}];
for event in &replay.events {
if event.bridge_instance_id != replay.bridge_instance_id || event.seq <= cursor.seq {
continue;
}
if event.seq != cursor.seq + 1 {
cursor.seq = replay.latest_seq;
output.push(WorkspaceHostEvent::ResetRequired {
bridge_instance_id: replay.bridge_instance_id.clone(),
latest_seq: replay.latest_seq,
reason: "sequenceGap".into(),
});
return output;
}
cursor.seq = event.seq;
output.push(WorkspaceHostEvent::Event {
event: event.clone(),
});
}
if cursor.seq < replay.latest_seq {
cursor.seq = replay.latest_seq;
output.push(WorkspaceHostEvent::ResetRequired {
bridge_instance_id: replay.bridge_instance_id.clone(),
latest_seq: replay.latest_seq,
reason: "sequenceGap".into(),
});
}
output
}
fn accept_live(
cursor: &mut SubscriptionCursor,
event: WorkspaceEvent,
) -> Option<WorkspaceHostEvent> {
if cursor.bridge_instance_id.as_deref() != Some(&event.bridge_instance_id) {
cursor.bridge_instance_id = Some(event.bridge_instance_id.clone());
cursor.seq = event.seq;
return Some(WorkspaceHostEvent::ResetRequired {
bridge_instance_id: event.bridge_instance_id,
latest_seq: event.seq,
reason: "epochChanged".into(),
});
}
if event.seq <= cursor.seq {
return None;
}
if event.seq != cursor.seq + 1 {
cursor.seq = event.seq;
return Some(WorkspaceHostEvent::ResetRequired {
bridge_instance_id: event.bridge_instance_id,
latest_seq: event.seq,
reason: "sequenceGap".into(),
});
}
cursor.seq = event.seq;
Some(WorkspaceHostEvent::Event { event })
}
#[derive(Debug, Default)]
pub struct SubscriptionGeneration(Mutex<u64>);
impl SubscriptionGeneration {
pub fn replace(&self) -> Result<u64, String> {
let mut current = self
.0
.lock()
.map_err(|_| "Could not replace workspace subscription".to_owned())?;
*current = current.saturating_add(1);
Ok(*current)
}
pub fn invalidate(&self) -> Result<(), String> {
self.replace().map(|_| ())
}
fn run_if_current<T>(
&self,
expected_generation: u64,
operation: impl FnOnce() -> Result<T, String>,
) -> Result<T, String> {
let current = self
.0
.lock()
.map_err(|_| "Could not inspect workspace subscription".to_owned())?;
if *current != expected_generation {
return Err("Workspace subscription was replaced".to_owned());
}
operation()
}
fn is_current(&self, expected_generation: u64) -> Result<bool, String> {
self.0
.lock()
.map(|current| *current == expected_generation)
.map_err(|_| "Could not inspect workspace subscription".to_owned())
}
}
fn retry_delay(attempt: usize) -> u64 {
match attempt {
0 => 250,
1 => 500,
2 => 1000,
_ => 2000,
}
}
fn emit_workspace(
app: &AppHandle,
generation: &SubscriptionGeneration,
expected_generation: u64,
event: WorkspaceHostEvent,
) -> Result<(), String> {
generation.run_if_current(expected_generation, || {
app.emit("workspace-bridge", event)
.map_err(|error| format!("Could not publish workspace event: {error}"))
})
}
async fn subscribe_workspace_once(
socket_path: &str,
cursor: &mut SubscriptionCursor,
app: &AppHandle,
generation: &SubscriptionGeneration,
expected_generation: u64,
) -> Result<(), String> {
let mut reader = connect_and_send(
socket_path,
"subscribe_workspace",
None,
Some(json!({ "cursor": cursor.seq })),
)
.await?;
let mut handshake = String::new();
if reader
.read_line(&mut handshake)
.await
.map_err(|error| format!("Bridge subscription failed: {error}"))?
== 0
{
return Err("Bridge subscription closed before handshake".into());
}
let replay_value: Value = serde_json::from_str(&handshake)
.map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?;
let replay: WorkspaceReplay = serde_json::from_value(result_from_response(replay_value)?)
.map_err(|error| format!("Bridge returned an invalid workspace replay: {error}"))?;
for event in accept_replay(cursor, &replay) {
emit_workspace(app, generation, expected_generation, event)?;
}
loop {
let mut line = String::new();
if reader
.read_line(&mut line)
.await
.map_err(|error| format!("Bridge subscription failed: {error}"))?
== 0
{
return Err("Bridge subscription closed".into());
}
let envelope: Value = serde_json::from_str(&line)
.map_err(|error| format!("Bridge emitted invalid JSON: {error}"))?;
let event: WorkspaceEvent = serde_json::from_value(
envelope
.get("event")
.cloned()
.ok_or_else(|| "Bridge event envelope is missing event".to_owned())?,
)
.map_err(|error| format!("Bridge emitted an invalid workspace event: {error}"))?;
if let Some(event) = accept_live(cursor, event) {
emit_workspace(app, generation, expected_generation, event)?;
}
}
}
pub async fn subscribe_workspace(
socket_path: String,
mut cursor: SubscriptionCursor,
app: AppHandle,
generation: Arc<SubscriptionGeneration>,
expected_generation: u64,
) -> Result<(), String> {
let mut attempt = 0usize;
loop {
if !generation.is_current(expected_generation)? {
return Ok(());
}
match subscribe_workspace_once(
&socket_path,
&mut cursor,
&app,
&generation,
expected_generation,
)
.await
{
Ok(()) => attempt = 0,
Err(error) => {
let delay = retry_delay(attempt);
if error == "Workspace subscription was replaced" {
return Ok(());
}
emit_workspace(
&app,
&generation,
expected_generation,
WorkspaceHostEvent::Disconnected {
message: error,
retry_in_ms: delay,
},
)?;
sleep(Duration::from_millis(delay)).await;
attempt = (attempt + 1).min(3);
}
}
}
}
pub async fn subscribe(
socket_path: String,
agent_id: String,
@@ -445,4 +945,361 @@ mod tests {
server.await.expect("server succeeds");
std::fs::remove_file(path).expect("socket cleanup");
}
fn runtime(id: &str) -> Value {
json!({
"runtimeId": id,
"worktreePath": "/tmp/project",
"state": "idle",
"label": "Session",
"attention": false,
"queueCount": 0,
"lastActivity": "2026-01-01T00:00:00Z",
"openedAt": "2026-01-01T00:00:00Z"
})
}
async fn assert_typed_frame<F, Fut>(operation: &'static str, payload: Value, call: F)
where
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<(), String>>,
{
let path = format!(
"{}/pi-status-ui-frame-{operation}-{}-{}.sock",
env::temp_dir().display(),
std::process::id(),
operation.len()
);
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).expect("listener");
let expected_payload = payload.clone();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("connection");
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let frame: Value = serde_json::from_str(&line).unwrap();
assert_eq!(frame["op"], operation);
assert_eq!(
frame.get("payload").cloned().unwrap_or(Value::Null),
expected_payload
);
let result = match operation {
"close_session_runtime" => json!({"runtimeId":"r"}),
"list_directory_sessions" => json!({"sessions":[]}),
"get_session_runtime_snapshot" => {
json!({"bridgeInstanceId":"b","latestSeq":0,"runtime":runtime("r"),"extensions":[]})
}
_ => json!({"runtime":runtime("r")}),
};
let response = format!("{}\n", json!({"id":"tauri-ui","ok":true,"result":result}));
reader
.get_mut()
.write_all(response.as_bytes())
.await
.unwrap();
});
call(path.clone()).await.unwrap();
server.await.unwrap();
std::fs::remove_file(path).unwrap();
}
async fn assert_no_payload<T, F, Fut>(operation: &'static str, result: Value, call: F) -> T
where
T: Send + 'static,
F: FnOnce(String) -> Fut,
Fut: std::future::Future<Output = Result<T, String>>,
{
let path = format!(
"{}/pi-status-ui-empty-{operation}-{}.sock",
env::temp_dir().display(),
std::process::id()
);
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let frame: Value = serde_json::from_str(&line).unwrap();
assert_eq!(frame["op"], operation);
assert!(frame.get("payload").is_none());
let response = format!("{}\n", json!({"id":"tauri-ui","ok":true,"result":result}));
reader
.get_mut()
.write_all(response.as_bytes())
.await
.unwrap();
});
let value = call(path.clone()).await.unwrap();
server.await.unwrap();
std::fs::remove_file(path).unwrap();
value
}
#[tokio::test]
async fn workspace_wrappers_emit_exact_operations_without_payloads() {
let workspace: Workspace = assert_no_payload(
"get_workspace",
json!({"bridgeInstanceId":"b","latestSeq":0,"directories":[]}),
|path| async move { get_workspace(&path).await },
)
.await;
assert_eq!(workspace.bridge_instance_id, "b");
let summary: WorkspaceSummary = assert_no_payload(
"get_workspace_summary",
json!({"bridgeInstanceId":"b","latestSeq":0,"openCount":0,"workingCount":0,"attentionCount":0,"recoveringCount":0,"errorCount":0,"directoryCount":0,"resourceWarning":false}),
|path| async move { get_workspace_summary(&path).await },
)
.await;
assert_eq!(summary.open_count, 0);
}
#[tokio::test]
async fn runtime_wrappers_emit_exact_operations_and_payloads() {
assert_typed_frame(
"create_session_runtime",
json!({"worktreePath":"/tmp/project"}),
|path| async move {
create_session_runtime(&path, "/tmp/project")
.await
.map(|_| ())
},
)
.await;
assert_typed_frame(
"open_session_runtime",
json!({"worktreePath":"/tmp/project","sessionPath":"/tmp/project/s.jsonl"}),
|path| async move {
open_session_runtime(&path, "/tmp/project", "/tmp/project/s.jsonl")
.await
.map(|_| ())
},
)
.await;
assert_typed_frame(
"close_session_runtime",
json!({"runtimeId":"r"}),
|path| async move { close_session_runtime(&path, "r").await.map(|_| ()) },
)
.await;
assert_typed_frame(
"list_directory_sessions",
json!({"worktreePath":"/tmp/project"}),
|path| async move {
list_directory_sessions(&path, "/tmp/project")
.await
.map(|_| ())
},
)
.await;
assert_typed_frame(
"get_session_runtime_snapshot",
json!({"runtimeId":"r"}),
|path| async move { get_session_runtime_snapshot(&path, "r").await.map(|_| ()) },
)
.await;
}
#[test]
fn decodes_multi_runtime_workspace_dormant_snapshot_and_directory_sessions() {
let workspace: Workspace = serde_json::from_value(json!({
"bridgeInstanceId":"bridge", "latestSeq":4,
"directories":[{"worktreePath":"/tmp/project","isHome":true,"openCount":2,"workingCount":1,"attentionCount":0,"recoveringCount":0,"errorCount":0,"runtimes":[runtime("one"),runtime("two")]}]
})).unwrap();
assert_eq!(workspace.directories[0].runtimes.len(), 2);
let snapshot: RuntimeSnapshot = serde_json::from_value(json!({"bridgeInstanceId":"bridge","latestSeq":4,"runtime":runtime("failed"),"extensions":[]})).unwrap();
assert!(snapshot.state.is_none());
let sessions: DirectorySessionsResult = serde_json::from_value(json!({
"sessions": [
{"path":"/tmp/project/one.jsonl","id":"one","cwd":"/tmp/project","modified":"2026-01-01T00:00:00Z","messageCount":2,"isCurrent":false,"runtimeId":"runtime-one"},
{"path":"/tmp/project/two.jsonl","id":"two","cwd":"/tmp/project","modified":"2026-01-02T00:00:00Z","messageCount":0,"isCurrent":false}
]
})).unwrap();
assert_eq!(
sessions.sessions[0].runtime_id.as_deref(),
Some("runtime-one")
);
assert!(sessions.sessions[1].runtime_id.is_none());
}
#[tokio::test]
async fn workspace_subscription_frame_uses_global_cursor_without_agent() {
let path = format!(
"{}/pi-status-ui-workspace-subscribe-{}.sock",
env::temp_dir().display(),
std::process::id()
);
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut reader = BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let frame: Value = serde_json::from_str(&line).unwrap();
assert_eq!(frame["op"], "subscribe_workspace");
assert_eq!(frame["payload"], json!({"cursor":17}));
assert!(frame.get("agentId").is_none());
});
let _reader = connect_and_send(
&path,
"subscribe_workspace",
None,
Some(json!({"cursor":17})),
)
.await
.unwrap();
server.await.unwrap();
std::fs::remove_file(path).unwrap();
}
fn event(epoch: &str, seq: u64) -> WorkspaceEvent {
serde_json::from_value(
json!({"bridgeInstanceId":epoch,"seq":seq,"type":"runtime_event","data":{}}),
)
.unwrap()
}
#[test]
fn workspace_host_events_use_exact_camel_case_json() {
let event = event("bridge-1", 7);
let cases = [
(
WorkspaceHostEvent::Connected {
bridge_instance_id: "bridge-1".into(),
first_available_seq: 3,
latest_seq: 7,
},
json!({
"kind": "connected",
"bridgeInstanceId": "bridge-1",
"firstAvailableSeq": 3,
"latestSeq": 7
}),
),
(
WorkspaceHostEvent::Event {
event: event.clone(),
},
json!({ "kind": "event", "event": event }),
),
(
WorkspaceHostEvent::ResetRequired {
bridge_instance_id: "bridge-1".into(),
latest_seq: 7,
reason: "sequenceGap".into(),
},
json!({
"kind": "resetRequired",
"bridgeInstanceId": "bridge-1",
"latestSeq": 7,
"reason": "sequenceGap"
}),
),
(
WorkspaceHostEvent::Disconnected {
message: "offline".into(),
retry_in_ms: 500,
},
json!({
"kind": "disconnected",
"message": "offline",
"retryInMs": 500
}),
),
];
for (host_event, expected) in cases {
assert_eq!(serde_json::to_value(host_event).unwrap(), expected);
}
}
#[test]
fn replacement_waits_for_an_in_flight_emission_and_rejects_stale_work() {
use std::sync::mpsc;
use std::thread;
use std::time::Duration as StdDuration;
let generation = Arc::new(SubscriptionGeneration::default());
let first = generation.replace().unwrap();
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let emitter_generation = Arc::clone(&generation);
let emitter = thread::spawn(move || {
emitter_generation.run_if_current(first, || {
entered_tx.send(()).unwrap();
release_rx.recv().unwrap();
Ok(())
})
});
entered_rx.recv().unwrap();
let replacement_generation = Arc::clone(&generation);
let (replaced_tx, replaced_rx) = mpsc::channel();
let replacement = thread::spawn(move || {
let next = replacement_generation.replace().unwrap();
replaced_tx.send(next).unwrap();
});
assert!(replaced_rx
.recv_timeout(StdDuration::from_millis(50))
.is_err());
release_tx.send(()).unwrap();
assert!(emitter.join().unwrap().is_ok());
assert_eq!(replaced_rx.recv().unwrap(), first + 1);
replacement.join().unwrap();
assert_eq!(
generation.run_if_current(first, || Ok(())),
Err("Workspace subscription was replaced".to_owned())
);
assert_eq!(
(0..6).map(retry_delay).collect::<Vec<_>>(),
vec![250, 500, 1000, 2000, 2000, 2000]
);
}
#[test]
fn replay_and_live_decisions_handle_epochs_truncation_and_gaps() {
let mut cursor = SubscriptionCursor {
bridge_instance_id: Some("a".into()),
seq: 1,
};
let replay = WorkspaceReplay {
bridge_instance_id: "a".into(),
first_available_seq: 1,
latest_seq: 3,
truncated: false,
events: vec![event("a", 2), event("a", 3)],
};
assert_eq!(accept_replay(&mut cursor, &replay).len(), 3);
assert_eq!(cursor.seq, 3);
assert!(matches!(
accept_live(&mut cursor, event("a", 4)),
Some(WorkspaceHostEvent::Event { .. })
));
assert!(accept_live(&mut cursor, event("a", 4)).is_none());
assert!(
matches!(accept_live(&mut cursor, event("a",6)), Some(WorkspaceHostEvent::ResetRequired { reason, .. }) if reason == "sequenceGap")
);
let changed = WorkspaceReplay {
bridge_instance_id: "b".into(),
first_available_seq: 1,
latest_seq: 9,
truncated: false,
events: vec![],
};
assert!(
matches!(&accept_replay(&mut cursor, &changed)[0], WorkspaceHostEvent::ResetRequired { reason, .. } if reason == "epochChanged")
);
let truncated = WorkspaceReplay {
bridge_instance_id: "b".into(),
first_available_seq: 10,
latest_seq: 12,
truncated: true,
events: vec![],
};
assert!(
matches!(&accept_replay(&mut cursor, &truncated)[0], WorkspaceHostEvent::ResetRequired { reason, .. } if reason == "replayTruncated")
);
}
}
+176 -5
View File
@@ -1,10 +1,29 @@
mod bridge;
mod ui_state;
use serde_json::Value;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use tauri::{async_runtime::JoinHandle, AppHandle, Emitter, Manager, State};
struct Subscription(Mutex<Option<JoinHandle<()>>>);
struct LegacySubscription(Mutex<Option<JoinHandle<()>>>);
struct WorkspaceSubscription {
generation: Arc<bridge::SubscriptionGeneration>,
task: Mutex<Option<JoinHandle<()>>>,
}
fn replace_subscription<T>(
task_slot: &Mutex<Option<T>>,
generation: &bridge::SubscriptionGeneration,
create: impl FnOnce(u64) -> T,
) -> Result<Option<T>, String> {
let mut slot = task_slot
.lock()
.map_err(|_| "Could not update workspace subscription".to_owned())?;
let expected_generation = generation.replace()?;
let previous = slot.take();
*slot = Some(create(expected_generation));
Ok(previous)
}
#[derive(Debug, PartialEq, Eq)]
enum WindowAction {
@@ -26,6 +45,66 @@ fn socket_path() -> Result<String, String> {
bridge::default_socket_path()
}
#[tauri::command]
async fn get_workspace() -> Result<bridge::Workspace, String> {
bridge::get_workspace(&socket_path()?).await
}
#[tauri::command]
async fn get_workspace_summary() -> Result<bridge::WorkspaceSummary, String> {
bridge::get_workspace_summary(&socket_path()?).await
}
#[tauri::command]
async fn create_session_runtime(worktree_path: String) -> Result<bridge::RuntimeResult, String> {
bridge::create_session_runtime(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn open_session_runtime(
worktree_path: String,
session_path: String,
) -> Result<bridge::RuntimeResult, String> {
bridge::open_session_runtime(&socket_path()?, &worktree_path, &session_path).await
}
#[tauri::command]
async fn close_session_runtime(runtime_id: String) -> Result<bridge::CloseRuntimeResult, String> {
bridge::close_session_runtime(&socket_path()?, &runtime_id).await
}
#[tauri::command]
async fn list_directory_sessions(
worktree_path: String,
) -> Result<bridge::DirectorySessionsResult, String> {
bridge::list_directory_sessions(&socket_path()?, &worktree_path).await
}
#[tauri::command]
async fn get_session_runtime_snapshot(
runtime_id: String,
) -> Result<bridge::RuntimeSnapshot, String> {
bridge::get_session_runtime_snapshot(&socket_path()?, &runtime_id).await
}
#[tauri::command]
fn load_ui_state(app: AppHandle) -> Result<ui_state::UiStateV1, String> {
let directory = app
.path()
.app_data_dir()
.map_err(|error| error.to_string())?;
ui_state::load_from(&directory)
}
#[tauri::command]
fn save_ui_state(app: AppHandle, state: ui_state::UiStateV1) -> Result<(), String> {
let directory = app
.path()
.app_data_dir()
.map_err(|error| error.to_string())?;
ui_state::save_to(&directory, &state)
}
#[tauri::command]
async fn list_agents() -> Result<Value, String> {
bridge::list_agents(&socket_path()?).await
@@ -96,6 +175,16 @@ async fn set_thinking_level(agent_id: String, level: String) -> Result<(), Strin
bridge::set_thinking_level(&socket_path()?, &agent_id, &level).await
}
#[tauri::command]
async fn set_session_name(agent_id: String, name: String) -> Result<(), String> {
bridge::set_session_name(&socket_path()?, &agent_id, &name).await
}
#[tauri::command]
async fn compact(agent_id: String, custom_instructions: Option<String>) -> Result<(), String> {
bridge::compact(&socket_path()?, &agent_id, custom_instructions.as_deref()).await
}
#[tauri::command]
async fn respond_to_extension(
agent_id: String,
@@ -108,7 +197,7 @@ async fn respond_to_extension(
#[tauri::command]
fn subscribe_agent(
app: AppHandle,
subscriptions: State<'_, Subscription>,
subscriptions: State<'_, LegacySubscription>,
agent_id: String,
cursor: u64,
) -> Result<(), String> {
@@ -140,11 +229,65 @@ fn subscribe_agent(
Ok(())
}
#[tauri::command]
fn subscribe_workspace(
app: AppHandle,
subscription: State<'_, WorkspaceSubscription>,
cursor: Option<ui_state::WorkspaceCursor>,
) -> Result<(), String> {
let socket = socket_path()?;
let event_app = app.clone();
let generation = Arc::clone(&subscription.generation);
let previous = replace_subscription(
&subscription.task,
&subscription.generation,
move |expected_generation| {
tauri::async_runtime::spawn(async move {
let initial = bridge::SubscriptionCursor {
bridge_instance_id: cursor
.as_ref()
.map(|value| value.bridge_instance_id.clone()),
seq: cursor.map(|value| value.seq).unwrap_or(0),
};
let _ = bridge::subscribe_workspace(
socket,
initial,
event_app,
generation,
expected_generation,
)
.await;
})
},
)?;
if let Some(task) = previous {
task.abort();
}
Ok(())
}
#[tauri::command]
fn unsubscribe_workspace(subscription: State<'_, WorkspaceSubscription>) -> Result<(), String> {
let mut slot = subscription
.task
.lock()
.map_err(|_| "Could not clear workspace subscription".to_owned())?;
subscription.generation.invalidate()?;
if let Some(task) = slot.take() {
task.abort();
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(Subscription(Mutex::new(None)))
.manage(LegacySubscription(Mutex::new(None)))
.manage(WorkspaceSubscription {
generation: Arc::new(bridge::SubscriptionGeneration::default()),
task: Mutex::new(None),
})
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
match window_action(&args, window.is_visible().unwrap_or(false)) {
@@ -159,6 +302,17 @@ pub fn run() {
}
}))
.invoke_handler(tauri::generate_handler![
get_workspace,
get_workspace_summary,
create_session_runtime,
open_session_runtime,
close_session_runtime,
list_directory_sessions,
get_session_runtime_snapshot,
load_ui_state,
save_ui_state,
subscribe_workspace,
unsubscribe_workspace,
list_agents,
list_directories,
select_worktree,
@@ -173,6 +327,8 @@ pub fn run() {
restart,
set_model,
set_thinking_level,
set_session_name,
compact,
respond_to_extension,
subscribe_agent
]);
@@ -186,6 +342,21 @@ pub fn run() {
mod tests {
use super::*;
#[test]
fn serialized_subscription_replacement_returns_the_previous_task() {
let tasks = Mutex::new(None::<u64>);
let generation = bridge::SubscriptionGeneration::default();
assert_eq!(
replace_subscription(&tasks, &generation, |current| current).unwrap(),
None
);
assert_eq!(
replace_subscription(&tasks, &generation, |current| current).unwrap(),
Some(1)
);
assert_eq!(*tasks.lock().unwrap(), Some(2));
}
#[test]
fn toggle_hides_a_visible_window_and_shows_a_hidden_window() {
let toggle = vec!["--toggle".to_owned()];
@@ -193,7 +364,7 @@ mod tests {
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),
window_action(&["--show".to_owned()], true),
WindowAction::ShowAndFocus
);
}
+440
View File
@@ -0,0 +1,440 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
const VERSION: u32 = 1;
const MAX_ITEMS: usize = 256;
const MAX_TOTAL_ITEMS: usize = 2048;
const MAX_DRAFT_BYTES: usize = 128 * 1024;
const MAX_TOTAL_DRAFT_BYTES: usize = 1024 * 1024;
const MAX_TOTAL_STRING_BYTES: usize = 1536 * 1024;
const MAX_FILE_BYTES: usize = 2 * 1024 * 1024;
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct WorkspaceCursor {
pub bridge_instance_id: String,
pub seq: u64,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ScrollState {
pub at_bottom: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scroll_top: Option<f64>,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LastSeenState {
pub bridge_instance_id: String,
pub seq: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_activity: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UiStateV1 {
pub version: u32,
#[serde(default)]
pub directory_order: Vec<String>,
#[serde(default)]
pub runtime_order_by_directory: BTreeMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_runtime_id: Option<String>,
#[serde(default)]
pub drafts: BTreeMap<String, String>,
#[serde(default)]
pub scroll: BTreeMap<String, ScrollState>,
#[serde(default)]
pub last_seen: BTreeMap<String, LastSeenState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_cursor: Option<WorkspaceCursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interface_scale: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dismissed_collision_warning: Option<bool>,
}
impl Default for UiStateV1 {
fn default() -> Self {
Self {
version: VERSION,
directory_order: Vec::new(),
runtime_order_by_directory: BTreeMap::new(),
selected_runtime_id: None,
drafts: BTreeMap::new(),
scroll: BTreeMap::new(),
last_seen: BTreeMap::new(),
workspace_cursor: None,
interface_scale: None,
dismissed_collision_warning: None,
}
}
}
fn bounded(value: &str, field: &str) -> Result<(), String> {
if value.is_empty() || value.len() > 4096 {
Err(format!("{field} must contain 1 to 4096 bytes"))
} else {
Ok(())
}
}
pub fn validate(state: &UiStateV1) -> Result<(), String> {
if state.version != VERSION {
return Err(format!("Unsupported UI state version: {}", state.version));
}
if state.directory_order.len() > MAX_ITEMS
|| state.runtime_order_by_directory.len() > MAX_ITEMS
|| state.drafts.len() > MAX_ITEMS
|| state.scroll.len() > MAX_ITEMS
|| state.last_seen.len() > MAX_ITEMS
{
return Err("UI state contains too many entries".to_owned());
}
let mut total_items = state.directory_order.len()
+ state.runtime_order_by_directory.len()
+ state.drafts.len()
+ state.scroll.len()
+ state.last_seen.len();
let mut total_string_bytes = 0usize;
for path in &state.directory_order {
bounded(path, "directory path")?;
total_string_bytes = total_string_bytes.saturating_add(path.len());
}
for (path, runtimes) in &state.runtime_order_by_directory {
bounded(path, "directory path")?;
total_string_bytes = total_string_bytes.saturating_add(path.len());
if runtimes.len() > MAX_ITEMS {
return Err("UI state contains too many runtime entries".to_owned());
}
total_items = total_items.saturating_add(runtimes.len());
for runtime_id in runtimes {
bounded(runtime_id, "runtime id")?;
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
}
}
if total_items > MAX_TOTAL_ITEMS {
return Err("UI state contains too many aggregate entries".to_owned());
}
if let Some(runtime_id) = &state.selected_runtime_id {
bounded(runtime_id, "selected runtime id")?;
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
}
let mut total_draft_bytes = 0usize;
for (runtime_id, draft) in &state.drafts {
bounded(runtime_id, "draft runtime id")?;
if draft.len() > MAX_DRAFT_BYTES {
return Err("A UI draft exceeds the size limit".to_owned());
}
total_draft_bytes = total_draft_bytes.saturating_add(draft.len());
total_string_bytes = total_string_bytes
.saturating_add(runtime_id.len())
.saturating_add(draft.len());
}
if total_draft_bytes > MAX_TOTAL_DRAFT_BYTES {
return Err("UI drafts exceed the total size limit".to_owned());
}
for (runtime_id, scroll) in &state.scroll {
bounded(runtime_id, "runtime id")?;
total_string_bytes = total_string_bytes.saturating_add(runtime_id.len());
if scroll
.scroll_top
.is_some_and(|value| !value.is_finite() || value < 0.0)
{
return Err("Scroll position is invalid".to_owned());
}
}
for (runtime_id, last_seen) in &state.last_seen {
bounded(runtime_id, "runtime id")?;
bounded(
&last_seen.bridge_instance_id,
"last-seen bridge instance id",
)?;
total_string_bytes = total_string_bytes
.saturating_add(runtime_id.len())
.saturating_add(last_seen.bridge_instance_id.len());
if let Some(activity) = &last_seen.last_activity {
bounded(activity, "last activity")?;
total_string_bytes = total_string_bytes.saturating_add(activity.len());
}
}
if let Some(cursor) = &state.workspace_cursor {
bounded(&cursor.bridge_instance_id, "bridge instance id")?;
total_string_bytes = total_string_bytes.saturating_add(cursor.bridge_instance_id.len());
}
if total_string_bytes > MAX_TOTAL_STRING_BYTES {
return Err("UI state strings exceed the total size limit".to_owned());
}
if let Some(scale) = state.interface_scale {
if !scale.is_finite() || !(0.5..=2.0).contains(&scale) {
return Err("Interface scale is outside the supported range".to_owned());
}
}
Ok(())
}
pub fn state_path(app_data_dir: &Path) -> PathBuf {
app_data_dir.join("ui-workspace-v1.json")
}
pub fn load_from(app_data_dir: &Path) -> Result<UiStateV1, String> {
let path = state_path(app_data_dir);
let mut file = match fs::File::open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(UiStateV1::default())
}
Err(error) => return Err(format!("Could not read UI state: {error}")),
};
let metadata = file
.metadata()
.map_err(|error| format!("Could not inspect UI state: {error}"))?;
if metadata.len() > MAX_FILE_BYTES as u64 {
return Err("UI state file exceeds the size limit".to_owned());
}
let mut content = Vec::with_capacity(metadata.len() as usize);
Read::by_ref(&mut file)
.take((MAX_FILE_BYTES + 1) as u64)
.read_to_end(&mut content)
.map_err(|error| format!("Could not read UI state: {error}"))?;
if content.len() > MAX_FILE_BYTES {
return Err("UI state file exceeds the size limit".to_owned());
}
let state: UiStateV1 = serde_json::from_slice(&content)
.map_err(|error| format!("UI state is corrupt or unsupported: {error}"))?;
validate(&state)?;
Ok(state)
}
pub fn save_to(app_data_dir: &Path, state: &UiStateV1) -> Result<(), String> {
validate(state)?;
fs::create_dir_all(app_data_dir)
.map_err(|error| format!("Could not create app data: {error}"))?;
set_mode(app_data_dir, 0o700)?;
let target = state_path(app_data_dir);
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temporary = app_data_dir.join(format!(".ui-workspace-{}-{nonce}.tmp", std::process::id()));
let encoded = serde_json::to_vec_pretty(state).map_err(|error| error.to_string())?;
if encoded.len().saturating_add(1) > MAX_FILE_BYTES {
return Err("UI state file exceeds the size limit".to_owned());
}
let result = (|| {
let mut file = create_private_file(&temporary)?;
file.write_all(&encoded)
.map_err(|error| format!("Could not write UI state: {error}"))?;
file.write_all(b"\n")
.map_err(|error| format!("Could not write UI state: {error}"))?;
file.sync_all()
.map_err(|error| format!("Could not sync UI state: {error}"))?;
fs::rename(&temporary, &target)
.map_err(|error| format!("Could not replace UI state: {error}"))?;
set_mode(&target, 0o600)?;
sync_directory(app_data_dir)?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<(), String> {
let directory = OpenOptions::new()
.read(true)
.open(path)
.map_err(|error| format!("Could not open app data directory for sync: {error}"))?;
directory
.sync_all()
.map_err(|error| format!("Could not sync app data directory: {error}"))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<(), String> {
Ok(())
}
#[cfg(unix)]
fn create_private_file(path: &Path) -> Result<std::fs::File, String> {
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(path)
.map_err(|error| format!("Could not create temporary UI state: {error}"))
}
#[cfg(not(unix))]
fn create_private_file(path: &Path) -> Result<std::fs::File, String> {
OpenOptions::new()
.create_new(true)
.write(true)
.open(path)
.map_err(|error| format!("Could not create temporary UI state: {error}"))
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode))
.map_err(|error| format!("Could not secure UI state path: {error}"))
}
#[cfg(not(unix))]
fn set_mode(_path: &Path, _mode: u32) -> Result<(), String> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"pi-ui-state-{name}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn owner_only_round_trip_and_missing_default() {
let root = temp_dir("roundtrip");
assert_eq!(load_from(&root).unwrap(), UiStateV1::default());
let mut state = UiStateV1 {
selected_runtime_id: Some("runtime-1".into()),
interface_scale: Some(1.1),
..UiStateV1::default()
};
state.drafts.insert("runtime-1".into(), "draft".into());
save_to(&root, &state).unwrap();
assert_eq!(load_from(&root).unwrap(), state);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&root).unwrap().permissions().mode() & 0o777,
0o700
);
assert_eq!(
fs::metadata(state_path(&root))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
fs::remove_dir_all(root).unwrap();
}
fn temporary_files(root: &Path) -> Vec<PathBuf> {
fs::read_dir(root)
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
name.starts_with(".ui-workspace-") && name.ends_with(".tmp")
})
})
.collect()
}
#[test]
fn second_save_replaces_valid_state_without_leaving_temporary_files() {
let root = temp_dir("replace");
let first = UiStateV1 {
selected_runtime_id: Some("runtime-1".into()),
..UiStateV1::default()
};
let second = UiStateV1 {
selected_runtime_id: Some("runtime-2".into()),
interface_scale: Some(1.2),
..UiStateV1::default()
};
save_to(&root, &first).unwrap();
save_to(&root, &second).unwrap();
assert_eq!(load_from(&root).unwrap(), second);
assert!(temporary_files(&root).is_empty());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn rejects_corruption_versions_unknown_fields_and_limits_without_overwrite() {
let root = temp_dir("invalid");
fs::create_dir_all(&root).unwrap();
let path = state_path(&root);
for invalid in [
"{broken",
r#"{"version":2}"#,
r#"{"version":1,"transcript":[]}"#,
] {
fs::write(&path, invalid).unwrap();
assert!(load_from(&root).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), invalid);
}
let baseline = UiStateV1 {
selected_runtime_id: Some("baseline".into()),
..UiStateV1::default()
};
save_to(&root, &baseline).unwrap();
let original = fs::read(&path).unwrap();
let mut oversized = UiStateV1::default();
oversized
.drafts
.insert("runtime".into(), "x".repeat(MAX_DRAFT_BYTES + 1));
assert!(save_to(&root, &oversized).is_err());
assert_eq!(fs::read(&path).unwrap(), original);
assert!(temporary_files(&root).is_empty());
fs::remove_dir_all(root).unwrap();
}
#[test]
fn rejects_aggregate_and_raw_file_size_limits() {
let root = temp_dir("aggregate");
fs::create_dir_all(&root).unwrap();
let mut aggregate = UiStateV1::default();
for directory in 0..9 {
aggregate.runtime_order_by_directory.insert(
format!("/directory-{directory}"),
(0..MAX_ITEMS)
.map(|runtime| format!("runtime-{directory}-{runtime}"))
.collect(),
);
}
assert!(validate(&aggregate)
.unwrap_err()
.contains("aggregate entries"));
let path = state_path(&root);
fs::write(&path, vec![b' '; MAX_FILE_BYTES + 1]).unwrap();
assert!(load_from(&root)
.unwrap_err()
.contains("file exceeds the size limit"));
assert_eq!(
fs::metadata(&path).unwrap().len(),
(MAX_FILE_BYTES + 1) as u64
);
assert!(temporary_files(&root).is_empty());
fs::remove_dir_all(root).unwrap();
}
}
+504 -777
View File
File diff suppressed because it is too large Load Diff
+478 -1585
View File
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
import { useEffect, useMemo, useRef } from "react";
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
export function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
.map((part) => {
if (part.type === "text") return part.text ?? "";
if (part.type === "thinking") return `Thinking: ${part.thinking ?? ""}`;
if (part.type === "toolCall") return `Tool: ${part.name ?? "running"}`;
return "";
})
.filter(Boolean)
.join("\n");
}
function todos(messages: RuntimeView["messages"]) {
const order: Record<TodoTask["status"], number> = {
in_progress: 0,
pending: 1,
completed: 2,
deleted: 3,
};
for (let index = messages.length - 1; index >= 0; index--) {
if (
messages[index].role === "toolResult" &&
messages[index].toolName === "todo" &&
Array.isArray(messages[index].details?.tasks)
)
return messages[index]
.details!.tasks!.filter((task) => task.status !== "deleted")
.sort((a, b) => order[a.status] - order[b.status]);
}
return [] as TodoTask[];
}
export function ConversationWorkspace({
runtime,
onDraft,
onSubmit,
onAbort,
onScroll,
onModel,
onThinking,
isSubmitting = false,
}: {
runtime: RuntimeView;
onDraft: (value: string) => void;
onSubmit: () => void;
onAbort: () => void;
onScroll: (atBottom: boolean, scrollTop: number) => void;
onModel: (model: Model) => void;
onThinking: (level: string) => void;
isSubmitting?: boolean;
}) {
const ref = useRef<HTMLElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const isWorking =
isSubmitting ||
runtime.progress.phase === "working" ||
runtime.progress.phase === "recovering";
useEffect(() => {
if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight;
}, [runtime.messages, runtime.pendingSubmissions, runtime.scroll.atBottom]);
return (
<div
className="workflow"
id="session-workspace"
role="tabpanel"
aria-labelledby={`session-tab-${runtime.summary.runtimeId}`}
>
<div className="workflow-main">
<section
className={`transcript ${isWorking ? "working" : runtime.progress.phase}`}
aria-label="Conversation"
ref={ref}
onScroll={(event) => {
const node = event.currentTarget;
onScroll(
node.scrollHeight - node.scrollTop - node.clientHeight <= 4,
node.scrollTop,
);
}}
>
{isWorking && (
<div className="agent-working" role="status" aria-live="polite">
<span className="working-bars" aria-hidden="true">
<i />
<i />
<i />
</span>
<div>
<strong>Pi is working</strong>
<span>
{isSubmitting
? "Sending your prompt…"
: runtime.progress.detail}
</span>
{runtime.progress.tool && (
<small>Using {runtime.progress.tool}</small>
)}
</div>
</div>
)}
{runtime.loading && !runtime.messages.length && (
<p className="muted">Loading session…</p>
)}
{!runtime.loading && !runtime.messages.length && (
<p className="muted">No messages yet.</p>
)}
{runtime.messages.map((message, index) => (
<article
className={`message ${message.role ?? "system"}`}
key={index}
>
<strong>{message.role ?? "message"}</strong>
<pre>{messageText(message)}</pre>
</article>
))}
{runtime.pendingSubmissions.map((pending) => (
<article
className={`message user pending-message ${pending.phase}`}
key={pending.id}
>
<strong>
You{" "}
<span>
{pending.phase === "sending"
? "Sending…"
: "Sent · waiting for Pi"}
</span>
</strong>
<pre>{pending.text}</pre>
</article>
))}
</section>
<aside className="todos-pane" aria-label="Current todos">
<div className="todos-heading">
<strong>Todos</strong>
<span>
{tasks.filter((task) => task.status !== "completed").length}
</span>
</div>
{tasks.length ? (
<ol className="todo-list">
{tasks.map((task) => (
<li className={task.status} key={task.id}>
<div>
<strong>{task.subject}</strong>
{task.activeForm && <small>{task.activeForm}</small>}
</div>
<span>{task.status.replace("_", " ")}</span>
</li>
))}
</ol>
) : (
<p className="muted">No active todos yet.</p>
)}
</aside>
</div>
<footer className="workflow-footer">
{Object.values(runtime.extensionWidgets)
.flatMap((widget) => widget.lines)
.map((line, index) => (
<div className="extension-widget" key={`${line}-${index}`}>
{line}
</div>
))}
<section
className="pi-controls"
aria-label="Model and thinking controls"
>
<label>
Model
<select
value={`${runtime.state.model?.provider ?? ""}/${runtime.state.model?.id ?? ""}`}
onChange={(event) => {
const [provider, id] = event.currentTarget.value.split("/");
onModel({ provider, id });
}}
>
<option value="">Current model</option>
{runtime.models.map((model) => (
<option
key={`${model.provider}/${model.id}`}
value={`${model.provider}/${model.id}`}
>
{model.provider}/{model.id ?? model.name}
</option>
))}
</select>
</label>
<label>
Thinking
<select
value={runtime.state.thinkingLevel ?? "medium"}
onChange={(event) => onThinking(event.currentTarget.value)}
>
{["off", "minimal", "low", "medium", "high", "xhigh", "max"].map(
(level) => (
<option key={level}>{level}</option>
),
)}
</select>
</label>
</section>
<div
className={`composer-progress ${isSubmitting ? "working" : runtime.progress.phase}`}
role="status"
aria-live="polite"
>
<span className="work-dot" />
<span>
{isSubmitting ? "Sending prompt to Pi…" : runtime.progress.detail}
</span>
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
</div>
<div className="composer">
<textarea
aria-label="Prompt"
value={runtime.draft}
disabled={isSubmitting}
onChange={(event) => onDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit();
}
}}
placeholder="Ask Pi…"
/>
<div className="composer-actions">
{runtime.state.isStreaming && (
<button type="button" className="quiet" onClick={onAbort}>
Abort
</button>
)}
<button
type="button"
disabled={
isSubmitting ||
!runtime.summary.agentId ||
!runtime.draft.trim()
}
onClick={onSubmit}
>
{isSubmitting ? "Sending…" : "Send"}
</button>
</div>
</div>
</footer>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { DirectoryWorkspace, WorkspaceState } from "../workspace/types";
type Props = {
state: WorkspaceState;
onSelect: (path: string) => void;
onAdd: () => void;
onForget: (directory: DirectoryWorkspace) => void;
};
export function DirectorySidebar({ state, onSelect, onAdd, onForget }: Props) {
return <aside className="directory-sidebar" aria-label="Directories">
<div className="sidebar-heading"><strong>Directories</strong><button onClick={onAdd} aria-label="Add directory">+</button></div>
<nav>{state.directoryOrder.map((path) => {
const directory = state.directoriesByPath[path]; if (!directory) return null;
const selected = path === state.selectedDirectoryPath;
return <div className={`directory-row ${selected ? "selected" : ""}`} key={path}>
<button className="directory-select" aria-current={selected ? "page" : undefined} onClick={() => onSelect(path)} title={path}>
<span>{path.split("/").filter(Boolean).slice(-1)[0] || path}</span>
<small>{directory.workingCount ? `${directory.workingCount} working` : `${directory.openCount} open`}</small>
<span className="directory-badges" aria-label={`${directory.attentionCount} attention, ${directory.errorCount} errors`}>
{directory.attentionCount > 0 && <b className="attention">!{directory.attentionCount}</b>}
{directory.errorCount > 0 && <b className="error">×{directory.errorCount}</b>}
</span>
</button>
{!directory.isHome && <button className="forget-directory" aria-label={`Forget ${path}`} onClick={() => onForget(directory)}>×</button>}
</div>;
})}</nav>
</aside>;
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from "react";
import type { Extension } from "../workspace/types";
function controls(container: HTMLElement | null) {
return [...(container?.querySelectorAll<HTMLElement>(
'button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
) ?? [])];
}
export function ExtensionDialog({ extension, onRespond }: { extension?: Extension; onRespond: (response: Record<string, unknown>) => void }) {
const ref = useRef<HTMLElement>(null);
const opener = useRef<HTMLElement | null>(null);
const respond = useRef(onRespond);
const [value, setValue] = useState("");
respond.current = onRespond;
useEffect(() => {
if (!extension) return;
opener.current = document.activeElement as HTMLElement | null;
setValue(extension.initialValue ?? extension.text ?? "");
const frame = requestAnimationFrame(() => controls(ref.current)[0]?.focus());
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
respond.current({ cancelled: true });
return;
}
if (event.key !== "Tab") return;
const focusable = controls(ref.current);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", keydown);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", keydown);
opener.current?.focus();
};
}, [extension]);
if (!extension) return null;
return <div className="extension-backdrop"><section ref={ref} className="extension" role="dialog" aria-modal="true" aria-labelledby="extension-title">
<h2 id="extension-title">{extension.title ?? "Pi needs input"}</h2>{extension.message && <p>{extension.message}</p>}
{extension.method === "select" && <div className="extension-options">{extension.options?.map((option) => <button key={option} onClick={() => onRespond({ value: option })}>{option}</button>)}</div>}
{extension.method === "confirm" && <div className="extension-options"><button onClick={() => onRespond({ confirmed: true })}>Confirm</button><button onClick={() => onRespond({ confirmed: false })}>Decline</button></div>}
{extension.method === "input" && <><input aria-label="Extension value" value={value} placeholder={extension.placeholder} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
{extension.method === "editor" && <><textarea aria-label="Extension value" value={value} onChange={(event) => setValue(event.currentTarget.value)} /><button onClick={() => onRespond({ value })}>Submit</button></>}
<button className="quiet" onClick={() => onRespond({ cancelled: true })}>Cancel</button>
</section></div>;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useRef } from "react";
import type { DirectorySession } from "../workspace/types";
export function SessionPicker({ sessions, onOpen, onClose }: { sessions: DirectorySession[]; onOpen: (session: DirectorySession) => void; onClose: () => void }) {
const dialogRef = useRef<HTMLElement>(null);
const openerRef = useRef<HTMLElement | null>(document.activeElement as HTMLElement | null);
useEffect(() => {
const dialog = dialogRef.current;
const controls = () => [...(dialog?.querySelectorAll<HTMLElement>('button:not(:disabled), [href], [tabindex]:not([tabindex="-1"])') ?? [])];
controls()[0]?.focus();
const keydown = (event: KeyboardEvent) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); return; }
if (event.key !== "Tab") return;
const focusable = controls(); const first = focusable[0]; const last = focusable[focusable.length - 1];
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
window.addEventListener("keydown", keydown);
return () => { window.removeEventListener("keydown", keydown); openerRef.current?.focus(); };
}, [onClose]);
return <div className="modal-backdrop"><section ref={dialogRef} className="session-picker" role="dialog" aria-modal="true" aria-labelledby="session-picker-title">
<div className="picker-heading"><h2 id="session-picker-title">Saved sessions</h2><button onClick={onClose}>Close</button></div>
{sessions.length ? <div className="session-list">{sessions.map((session) => <button key={session.path} onClick={() => onOpen(session)}>
<span>{session.name ?? session.firstMessage ?? "Untitled session"}</span><small>{session.runtimeId ? "Already open" : `${session.messageCount} messages · ${new Date(session.modified).toLocaleDateString()}`}</small>
</button>)}</div> : <p className="muted">No saved sessions for this directory.</p>}
</section></div>;
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useRef, type KeyboardEvent } from "react";
import type { RuntimeView } from "../workspace/types";
type Props = {
runtimes: RuntimeView[];
selectedId?: string;
onSelect: (id: string) => void;
onClose: (runtime: RuntimeView) => void;
onNew: () => void;
onResume: () => void;
busy?: boolean;
closingRuntimeId?: string;
restoreFocus?: boolean;
onFocusRestored?: () => void;
};
export function SessionTabs({
runtimes,
selectedId,
onSelect,
onClose,
onNew,
onResume,
busy,
closingRuntimeId,
restoreFocus,
onFocusRestored,
}: Props) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({});
const newSessionRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!restoreFocus) return;
(selectedId ? refs.current[selectedId] : newSessionRef.current)?.focus();
onFocusRestored?.();
}, [restoreFocus, selectedId, onFocusRestored]);
function keydown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
let next: number | undefined;
if (!runtimes.length) return;
if (event.key === "ArrowRight") next = (index + 1) % runtimes.length;
else if (event.key === "ArrowLeft")
next = (index - 1 + runtimes.length) % runtimes.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = runtimes.length - 1;
if (next === undefined) return;
event.preventDefault();
refs.current[runtimes[next].summary.runtimeId]?.focus();
onSelect(runtimes[next].summary.runtimeId);
}
return (
<div className="session-strip">
<div className="session-tab-bar" aria-label="Sessions">
<div className="session-tabs" role="tablist" aria-label="Open sessions">
{runtimes.map((runtime, index) => {
const summary = runtime.summary;
const selected = summary.runtimeId === selectedId;
return (
<div
className={`session-tab ${selected ? "selected" : ""}`}
key={summary.runtimeId}
>
<button
role="tab"
id={`session-tab-${summary.runtimeId}`}
aria-controls="session-workspace"
aria-selected={selected}
tabIndex={selected || (!selectedId && index === 0) ? 0 : -1}
ref={(element) => {
refs.current[summary.runtimeId] = element;
}}
onKeyDown={(event) => keydown(event, index)}
onClick={() => onSelect(summary.runtimeId)}
>
<span>{summary.label}</span>
<small className={summary.state}>
{summary.state}
{summary.queueCount
? ` · ${summary.queueCount} queued`
: ""}
</small>
{runtime.unread && (
<i className="unread-dot" aria-label="Unread activity" />
)}
{summary.attention && (
<b className="attention-dot" aria-label="Needs attention">
!
</b>
)}
</button>
<button
className="close-session"
aria-label={`Close ${summary.label}`}
disabled={closingRuntimeId === summary.runtimeId}
onClick={() => onClose(runtime)}
>
{closingRuntimeId === summary.runtimeId ? "…" : "×"}
</button>
</div>
);
})}
</div>
<div className="session-tab-actions" aria-label="Session actions">
<button className="load-session" disabled={busy} onClick={onResume}>
<span aria-hidden="true">⌄</span>
{busy ? "Working…" : "Load"}
</button>
<button
className="new-session"
ref={newSessionRef}
disabled={busy}
onClick={onNew}
>
<span aria-hidden="true">+</span>
{busy ? "Starting…" : "New"}
</button>
</div>
</div>
</div>
);
}
+155
View File
@@ -0,0 +1,155 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DirectorySidebar } from "./DirectorySidebar";
import { SessionTabs } from "./SessionTabs";
import { SessionPicker } from "./SessionPicker";
import {
initialWorkspaceState,
reconcileWorkspace,
runtimeView,
} from "../workspace/reducer";
import type { Workspace } from "../workspace/types";
const summary = (id: string, state = "idle") => ({
runtimeId: id,
worktreePath: "/repo",
state,
label: id,
attention: id === "b",
queueCount: id === "b" ? 2 : 0,
lastActivity: id,
openedAt: id,
});
const workspace: Workspace = {
bridgeInstanceId: "e",
latestSeq: 0,
directories: [
{
worktreePath: "/repo",
isHome: false,
openCount: 2,
workingCount: 1,
attentionCount: 1,
recoveringCount: 0,
errorCount: 1,
runtimes: [summary("a"), summary("b", "streaming")],
},
],
};
describe("multi-session navigation", () => {
it("renders directory aggregates and accessible keyboard session tabs", async () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const onSelect = vi.fn();
const { rerender } = render(
<>
<DirectorySidebar
state={state}
onSelect={vi.fn()}
onAdd={vi.fn()}
onForget={vi.fn()}
/>
<SessionTabs
runtimes={[
runtimeView(summary("a")),
runtimeView(summary("b", "streaming")),
]}
selectedId="a"
onSelect={onSelect}
onClose={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
/>
</>,
);
expect(screen.getByLabelText(/1 attention, 1 errors/)).toBeInTheDocument();
const tabs = screen.getAllByRole("tab");
expect(tabs[0]).toHaveAttribute("aria-selected", "true");
await userEvent.click(tabs[0]);
await userEvent.keyboard("{ArrowRight}");
expect(onSelect).toHaveBeenCalledWith("b");
rerender(
<SessionTabs
runtimes={[runtimeView(summary("b", "streaming"))]}
selectedId="b"
onSelect={onSelect}
onClose={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
/>,
);
expect(screen.getByRole("tab")).toHaveAccessibleName(/b/);
});
it("restores focus to the selected adjacent tab after close", () => {
const onFocusRestored = vi.fn();
render(
<SessionTabs
runtimes={[runtimeView(summary("a")), runtimeView(summary("b"))]}
selectedId="b"
onSelect={vi.fn()}
onClose={vi.fn()}
onNew={vi.fn()}
onResume={vi.fn()}
restoreFocus
onFocusRestored={onFocusRestored}
/>,
);
expect(screen.getByRole("tab", { name: /b/ })).toHaveFocus();
expect(onFocusRestored).toHaveBeenCalled();
});
it("traps and restores focus for the saved-session picker", async () => {
const user = userEvent.setup();
const opener = document.createElement("button");
document.body.append(opener);
opener.focus();
const onClose = vi.fn();
const { unmount } = render(
<SessionPicker
sessions={[
{
path: "/s",
id: "s",
cwd: "/repo",
modified: new Date().toISOString(),
messageCount: 0,
},
]}
onOpen={vi.fn()}
onClose={onClose}
/>,
);
const close = screen.getByRole("button", { name: "Close" });
expect(close).toHaveFocus();
await user.keyboard("{Tab}");
expect(
screen.getByRole("button", { name: /Untitled session/ }),
).toHaveFocus();
await user.keyboard("{Tab}");
expect(close).toHaveFocus();
await user.keyboard("{Escape}");
expect(onClose).toHaveBeenCalled();
unmount();
expect(opener).toHaveFocus();
document.body.removeChild(opener);
});
it("routes new, saved-open, and close controls through callbacks", async () => {
const onNew = vi.fn(),
onResume = vi.fn(),
onClose = vi.fn();
render(
<SessionTabs
runtimes={[runtimeView(summary("a"))]}
selectedId="a"
onSelect={vi.fn()}
onClose={onClose}
onNew={onNew}
onResume={onResume}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "New" }));
await userEvent.click(screen.getByRole("button", { name: "Load" }));
await userEvent.click(screen.getByLabelText("Close a"));
expect(onNew).toHaveBeenCalled();
expect(onResume).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
});
+4
View File
@@ -0,0 +1,4 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(cleanup);
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { legacyPreferences, removeLegacyPreferences, presentationState } from "./persistence";
import { initialWorkspaceState, reconcileWorkspace, workspaceReducer } from "./reducer";
import type { Workspace } from "./types";
const workspace: Workspace = { bridgeInstanceId: "e", latestSeq: 7, directories: [{ worktreePath: "/repo", isHome: true, openCount: 1, workingCount: 0, attentionCount: 0, recoveringCount: 0, errorCount: 0, runtimes: [{ runtimeId: "r", worktreePath: "/repo", state: "idle", label: "r", attention: false, queueCount: 0, lastActivity: "now", openedAt: "now", agentId: "ephemeral" }] }] };
describe("presentation persistence", () => {
it("persists only allowlisted presentation state, never agent or session payloads", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace); state = workspaceReducer(state, { type: "draftChanged", runtimeId: "r", draft: "safe draft" });
const persisted = presentationState(state); const encoded = JSON.stringify(persisted);
expect(persisted.drafts.r).toBe("safe draft"); expect(encoded).not.toContain("ephemeral"); expect(encoded).not.toContain("messages"); expect(encoded).not.toContain("extensions");
});
it("reads and removes legacy worktree/scale only after caller saves", () => {
const getItem = vi.fn((key: string) => key.includes("selected") ? "/repo" : "1.2"); expect(legacyPreferences({ getItem })).toEqual({ path: "/repo", scale: 1.2 });
const removeItem = vi.fn(); removeLegacyPreferences({ removeItem }); expect(removeItem).toHaveBeenCalledTimes(2);
});
});
+56
View File
@@ -0,0 +1,56 @@
import { invoke } from "@tauri-apps/api/core";
import type { UiState, WorkspaceState } from "./types";
export const selectedPathKey = "pi-status-ui.selected-worktree";
export const uiScaleKey = "pi-status-ui.interface-scale";
export function presentationState(state: WorkspaceState): UiState {
const drafts: Record<string, string> = {};
const scroll: UiState["scroll"] = {};
const lastSeen: UiState["lastSeen"] = {};
for (const [id, runtime] of Object.entries(state.runtimesById)) {
if (runtime.draft) drafts[id] = runtime.draft;
scroll[id] = runtime.scroll;
if (!runtime.unread && state.bridge.instanceId)
lastSeen[id] = {
bridgeInstanceId: state.bridge.instanceId,
seq: state.bridge.latestSeq,
lastActivity: runtime.summary.lastActivity,
};
}
return {
version: 1,
directoryOrder: state.directoryOrder,
runtimeOrderByDirectory: state.runtimeOrderByDirectory,
selectedRuntimeId: state.selectedRuntimeId,
drafts,
scroll,
lastSeen,
workspaceCursor: state.bridge.instanceId
? {
bridgeInstanceId: state.bridge.instanceId,
seq: state.bridge.latestSeq,
}
: undefined,
interfaceScale: state.interfaceScale,
dismissedCollisionWarning: state.dismissedCollisionWarning,
};
}
export async function loadPresentation() {
return invoke<UiState>("load_ui_state");
}
export async function savePresentation(state: UiState) {
await invoke("save_ui_state", { state });
}
export function legacyPreferences(storage: Pick<Storage, "getItem">) {
const path = storage.getItem(selectedPathKey) ?? undefined;
const raw = Number(storage.getItem(uiScaleKey));
const scale =
Number.isFinite(raw) && raw >= 0.9 && raw <= 1.4 ? raw : undefined;
return { path, scale };
}
export function removeLegacyPreferences(storage: Pick<Storage, "removeItem">) {
storage.removeItem(selectedPathKey);
storage.removeItem(uiScaleKey);
}
+311
View File
@@ -0,0 +1,311 @@
import { describe, expect, it } from "vitest";
import {
applyWorkspaceEvent,
initialWorkspaceState,
reconcileWorkspace,
workspaceReducer,
} from "./reducer";
import type { RuntimeSnapshot, Workspace } from "./types";
const runtime = (id: string, path = "/repo") => ({
runtimeId: id,
worktreePath: path,
state: "idle",
label: id,
attention: false,
queueCount: 0,
lastActivity: id,
openedAt: id,
});
const workspace: Workspace = {
bridgeInstanceId: "epoch",
latestSeq: 0,
directories: [
{
worktreePath: "/repo",
isHome: false,
openCount: 2,
workingCount: 0,
attentionCount: 0,
recoveringCount: 0,
errorCount: 0,
runtimes: [runtime("a"), runtime("b")],
},
],
};
describe("workspace reducer", () => {
it("isolates drafts, snapshots and background unread by runtime", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "a",
draft: "draft a",
});
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "b",
generation: 1,
});
const snapshot: RuntimeSnapshot = {
bridgeInstanceId: "epoch",
latestSeq: 0,
runtime: runtime("b"),
transcript: { data: { messages: [{ role: "user", content: "b" }] } },
extensions: [],
};
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "b",
generation: 1,
snapshot,
});
state = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_event",
data: {
runtimeId: "b",
eventType: "transcript",
eventData: { event: { type: "message_end" } },
},
});
expect(state.runtimesById.a.draft).toBe("draft a");
expect(state.runtimesById.a.messages).toEqual([]);
expect(state.runtimesById.b.messages[0].content).toBe("b");
expect(state.runtimesById.b.unread).toBe(true);
});
it("removes the optimistic submission when Pi returns its structured user message", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "submissionAdded",
runtimeId: "a",
submission: {
id: 1,
text: "Hello Pi",
userMessageCount: 0,
phase: "sent",
},
});
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
const snapshot: RuntimeSnapshot = {
bridgeInstanceId: "epoch",
latestSeq: 1,
runtime: runtime("a"),
transcript: {
data: {
messages: [
{ role: "user", content: [{ type: "text", text: "Hello Pi" }] },
],
},
},
extensions: [],
};
state = workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot,
});
expect(state.runtimesById.a.pendingSubmissions).toEqual([]);
});
it("rejects out-of-order snapshots and detects duplicate/gap/epoch events", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "snapshotRequested",
runtimeId: "a",
generation: 2,
});
const snap: RuntimeSnapshot = {
bridgeInstanceId: "epoch",
latestSeq: 0,
runtime: runtime("a"),
extensions: [],
};
expect(
workspaceReducer(state, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot: snap,
}).runtimesById.a.loading,
).toBe(true);
const first = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
data: { runtimeId: "a", runtime: runtime("a") },
});
expect(
applyWorkspaceEvent(first, {
bridgeInstanceId: "epoch",
seq: 1,
data: { runtimeId: "a" },
}),
).toBe(first);
expect(
applyWorkspaceEvent(first, {
bridgeInstanceId: "epoch",
seq: 3,
data: { runtimeId: "a" },
}).bridge.status,
).toBe("resyncing");
expect(
applyWorkspaceEvent(first, {
bridgeInstanceId: "new",
seq: 2,
data: { runtimeId: "a" },
}).bridge.status,
).toBe("resyncing");
});
it("selects an adjacent tab on close, preserves drafts, and keeps failed dormant runtimes", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace, {
version: 1,
directoryOrder: ["/repo"],
runtimeOrderByDirectory: { "/repo": ["b", "a"] },
selectedRuntimeId: "b",
drafts: { a: "saved" },
scroll: {},
lastSeen: {},
});
expect(state.runtimesById.a.draft).toBe("saved");
state = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_closed",
data: { runtimeId: "b" },
});
expect(state.selectedRuntimeId).toBe("a");
const failed: Workspace = {
...workspace,
directories: [
{
...workspace.directories[0],
runtimes: [
{
...runtime("failed"),
state: "failed",
attention: true,
error: { message: "missing" },
},
],
},
],
};
expect(
reconcileWorkspace(initialWorkspaceState, failed).runtimesById.failed
.summary.agentId,
).toBeUndefined();
});
it("accepts fresh snapshots after a bridge epoch reset while preserving UI state", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state = workspaceReducer(state, {
type: "draftChanged",
runtimeId: "a",
draft: "keep me",
});
state = workspaceReducer(state, {
type: "scrollChanged",
runtimeId: "a",
atBottom: false,
scrollTop: 42,
});
state.runtimesById.a.loadedThroughSeq = 100;
state = workspaceReducer(state, {
type: "hostEvent",
host: {
kind: "resetRequired",
bridgeInstanceId: "new-epoch",
latestSeq: 2,
reason: "epochChanged",
},
});
const restored = reconcileWorkspace(state, {
...workspace,
bridgeInstanceId: "new-epoch",
latestSeq: 2,
});
expect(restored.runtimesById.a.loadedThroughSeq).toBe(0);
expect(restored.runtimesById.a.draft).toBe("keep me");
expect(restored.runtimesById.a.scroll.scrollTop).toBe(42);
const requested = workspaceReducer(restored, {
type: "snapshotRequested",
runtimeId: "a",
generation: 1,
});
const snapshot: RuntimeSnapshot = {
bridgeInstanceId: "new-epoch",
latestSeq: 2,
runtime: runtime("a"),
extensions: [],
};
const accepted = workspaceReducer(requested, {
type: "snapshotLoaded",
runtimeId: "a",
generation: 1,
snapshot,
});
expect(accepted.runtimesById.a.loadedThroughSeq).toBe(2);
expect(accepted.runtimesById.a.extensions).toEqual([]);
expect(accepted.runtimesById.a.messages).toEqual([]);
});
it("clears stale extension and transcript state for every runtime after an epoch change", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.b.messages = [{ role: "assistant", content: "old" }];
state.runtimesById.b.extensions = [
{ id: "dead-request", method: "confirm" },
];
state.runtimesById.b.unread = true;
const reset = workspaceReducer(state, {
type: "hostEvent",
host: {
kind: "resetRequired",
bridgeInstanceId: "new-epoch",
latestSeq: 1,
reason: "epochChanged",
},
});
const restored = reconcileWorkspace(reset, {
...workspace,
bridgeInstanceId: "new-epoch",
latestSeq: 1,
});
expect(restored.runtimesById.b.messages).toEqual([]);
expect(restored.runtimesById.b.extensions).toEqual([]);
expect(restored.runtimesById.b.unread).toBe(false);
});
it("requests reconciliation when a runtime lifecycle event changes the open tabs", () => {
const state = reconcileWorkspace(initialWorkspaceState, workspace);
const next = applyWorkspaceEvent(state, {
bridgeInstanceId: "epoch",
seq: 1,
type: "runtime_opened",
data: { runtimeId: "external", runtime: runtime("external") },
});
expect(next.bridge.status).toBe("resyncing");
});
it("clears unread only when selected, active, and at latest", () => {
let state = reconcileWorkspace(initialWorkspaceState, workspace);
state.runtimesById.a.unread = true;
state = workspaceReducer(state, { type: "setWindowActive", active: false });
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
expect(state.runtimesById.a.unread).toBe(true);
state = workspaceReducer(state, { type: "setWindowActive", active: true });
state = workspaceReducer(state, {
type: "scrollChanged",
runtimeId: "a",
atBottom: false,
});
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
expect(state.runtimesById.a.unread).toBe(true);
state = workspaceReducer(state, {
type: "scrollChanged",
runtimeId: "a",
atBottom: true,
});
state = workspaceReducer(state, { type: "markRead", runtimeId: "a" });
expect(state.runtimesById.a.unread).toBe(false);
});
});
+507
View File
@@ -0,0 +1,507 @@
import type {
Extension,
HostEvent,
RuntimeSnapshot,
RuntimeSummary,
RuntimeView,
UiState,
Workspace,
WorkspaceEvent,
WorkspaceState,
} from "./types";
const idleProgress = {
phase: "idle" as const,
detail: "Ready for your next prompt",
toolCount: 0,
};
function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
return (message.content ?? [])
.filter((part) => part.type === "text")
.map((part) => part.text ?? "")
.join("");
}
export function runtimeView(
summary: RuntimeSummary,
previous?: RuntimeView,
): RuntimeView {
return {
summary,
state: previous?.state ?? {},
stats: previous?.stats ?? {},
messages: previous?.messages ?? [],
commands: previous?.commands ?? [],
models: previous?.models ?? [],
extensions: previous?.extensions ?? [],
extensionStatuses: previous?.extensionStatuses ?? {},
extensionWidgets: previous?.extensionWidgets ?? {},
notifications: previous?.notifications ?? [],
progress: previous?.progress ?? idleProgress,
pendingSubmissions: previous?.pendingSubmissions ?? [],
draft: previous?.draft ?? "",
scroll: previous?.scroll ?? { atBottom: true },
unread: previous?.unread ?? false,
loading: previous?.loading ?? false,
error: previous?.error,
requestGeneration: previous?.requestGeneration ?? 0,
loadedThroughSeq: previous?.loadedThroughSeq ?? 0,
};
}
function resetRuntimeView(
summary: RuntimeSummary,
previous: RuntimeView,
): RuntimeView {
return {
...runtimeView(summary, previous),
state: {},
stats: {},
messages: [],
commands: [],
models: [],
extensions: [],
extensionStatuses: {},
extensionWidgets: {},
notifications: [],
pendingSubmissions: [],
unread: false,
progress: idleProgress,
loading: false,
error: undefined,
requestGeneration: 0,
loadedThroughSeq: 0,
};
}
export const initialWorkspaceState: WorkspaceState = {
bridge: { latestSeq: 0, status: "connecting" },
directoriesByPath: {},
directoryOrder: [],
runtimesById: {},
runtimeOrderByDirectory: {},
windowActive: true,
resourceWarning: false,
dismissedCollisionWarning: false,
interfaceScale: 1,
};
function ordered(current: string[] | undefined, authoritative: string[]) {
const valid = new Set(authoritative);
return [
...(current ?? []).filter((id) => valid.has(id)),
...authoritative.filter((id) => !(current ?? []).includes(id)),
];
}
export function reconcileWorkspace(
state: WorkspaceState,
workspace: Workspace,
persisted?: UiState,
): WorkspaceState {
const epochChanged = Boolean(
state.bridge.instanceId &&
state.bridge.instanceId !== workspace.bridgeInstanceId,
);
const directoriesByPath: WorkspaceState["directoriesByPath"] = {};
const runtimesById: WorkspaceState["runtimesById"] = {};
const runtimeOrderByDirectory: WorkspaceState["runtimeOrderByDirectory"] = {};
for (const directory of workspace.directories) {
directoriesByPath[directory.worktreePath] = directory;
for (const runtime of directory.runtimes) {
const previous = state.runtimesById[runtime.runtimeId];
runtimesById[runtime.runtimeId] =
epochChanged && previous
? resetRuntimeView(runtime, previous)
: runtimeView(runtime, previous);
}
const ids = directory.runtimes.map((runtime) => runtime.runtimeId);
runtimeOrderByDirectory[directory.worktreePath] = ordered(
persisted?.runtimeOrderByDirectory[directory.worktreePath] ??
state.runtimeOrderByDirectory[directory.worktreePath],
ids,
);
}
const paths = workspace.directories.map(
(directory) => directory.worktreePath,
);
const directoryOrder = ordered(
persisted?.directoryOrder ?? state.directoryOrder,
paths,
);
const preferred = persisted?.selectedRuntimeId ?? state.selectedRuntimeId;
const selectedRuntimeId =
preferred && runtimesById[preferred]
? preferred
: directoryOrder.flatMap(
(path) => runtimeOrderByDirectory[path] ?? [],
)[0];
const selectedDirectoryPath = selectedRuntimeId
? runtimesById[selectedRuntimeId]?.summary.worktreePath
: directoryOrder[0];
if (persisted) {
for (const [id, view] of Object.entries(runtimesById)) {
view.draft = persisted.drafts[id] ?? view.draft;
view.scroll = persisted.scroll[id] ?? view.scroll;
const seen = persisted.lastSeen[id];
if (
seen?.bridgeInstanceId === workspace.bridgeInstanceId &&
seen.seq < workspace.latestSeq &&
seen.lastActivity !== view.summary.lastActivity
)
view.unread = true;
}
}
return {
...state,
bridge: {
instanceId: workspace.bridgeInstanceId,
latestSeq: workspace.latestSeq,
status: "ready",
},
directoriesByPath,
directoryOrder,
runtimesById,
runtimeOrderByDirectory,
selectedRuntimeId,
selectedDirectoryPath,
dismissedCollisionWarning:
persisted?.dismissedCollisionWarning ?? state.dismissedCollisionWarning,
interfaceScale: persisted?.interfaceScale ?? state.interfaceScale,
};
}
function meaningful(eventType?: string, event?: Record<string, unknown>) {
if (["extension_ui_request", "agent_state"].includes(eventType ?? ""))
return true;
if (eventType === "transcript")
return ["message_end", "turn_end", "agent_end"].includes(
String(event?.type ?? ""),
);
return false;
}
function extensionPresentation(view: RuntimeView, extension: Extension) {
if (["select", "confirm", "input", "editor"].includes(extension.method ?? ""))
view.extensions = [
...view.extensions.filter((entry) => entry.id !== extension.id),
extension,
];
else if (extension.method === "notify")
view.notifications = [...view.notifications, extension].slice(-4);
else if (extension.method === "setStatus" && extension.statusKey) {
const next = { ...view.extensionStatuses };
if (extension.statusText) next[extension.statusKey] = extension.statusText;
else delete next[extension.statusKey];
view.extensionStatuses = next;
} else if (extension.method === "setWidget" && extension.widgetKey) {
const next = { ...view.extensionWidgets };
if (extension.widgetLines)
next[extension.widgetKey] = {
lines: extension.widgetLines,
placement: extension.widgetPlacement ?? "aboveEditor",
};
else delete next[extension.widgetKey];
view.extensionWidgets = next;
} else if (extension.method === "set_editor_text")
view.draft = extension.text ?? "";
}
export function applyWorkspaceEvent(
state: WorkspaceState,
event: WorkspaceEvent,
): WorkspaceState {
if (
state.bridge.instanceId &&
event.bridgeInstanceId !== state.bridge.instanceId
)
return {
...state,
bridge: {
...state.bridge,
status: "resyncing",
error: "Bridge restarted",
},
};
if (event.seq <= state.bridge.latestSeq) return state;
if (event.seq !== state.bridge.latestSeq + 1)
return {
...state,
bridge: {
...state.bridge,
status: "resyncing",
error: "Workspace event gap",
},
};
const id = event.data?.runtimeId;
if (
["runtime_opened", "runtime_recovered", "runtime_restore_failed"].includes(
event.type ?? "",
)
)
return {
...state,
bridge: { ...state.bridge, latestSeq: event.seq, status: "resyncing" },
};
if (!id || (!state.runtimesById[id] && !event.data?.runtime))
return {
...state,
bridge: {
...state.bridge,
latestSeq: event.seq,
status: "resyncing",
error: "Unknown runtime",
},
};
const runtimesById = { ...state.runtimesById };
let view = runtimeView(
event.data?.runtime ?? runtimesById[id].summary,
runtimesById[id],
);
view = { ...view, summary: event.data?.runtime ?? view.summary };
if (event.type === "runtime_closed") {
delete runtimesById[id];
const path = view.summary.worktreePath;
const order = (state.runtimeOrderByDirectory[path] ?? []).filter(
(runtimeId) => runtimeId !== id,
);
const runtimeOrderByDirectory = {
...state.runtimeOrderByDirectory,
[path]: order,
};
const selectedRuntimeId =
state.selectedRuntimeId === id
? (order[0] ?? Object.keys(runtimesById)[0])
: state.selectedRuntimeId;
return {
...state,
bridge: { ...state.bridge, latestSeq: event.seq },
runtimesById,
runtimeOrderByDirectory,
selectedRuntimeId,
selectedDirectoryPath: selectedRuntimeId
? runtimesById[selectedRuntimeId]?.summary.worktreePath
: path,
};
}
const type = event.data?.eventType;
const data = event.data?.eventData;
const payload = data?.event ?? {};
if (type === "agent_state" && data?.state) {
view.summary = { ...view.summary, state: data.state };
view.progress =
data.state === "streaming"
? {
...view.progress,
phase: "working",
detail: "Thinking and preparing a response",
}
: data.state === "recovering"
? {
...view.progress,
phase: "recovering",
detail: "Recovering the Pi session",
}
: data.state === "error"
? { ...view.progress, phase: "error", detail: "Pi needs attention" }
: idleProgress;
} else if (type === "tool") {
const ending = String(payload.type ?? "").endsWith("_end");
view.progress = {
...view.progress,
phase: "working",
detail: ending ? "Continuing after tool result" : "Running a tool",
tool: ending ? undefined : String(payload.toolName ?? "tool"),
toolCount:
view.progress.toolCount +
(String(payload.type ?? "").endsWith("_start") ? 1 : 0),
};
} else if (type === "queue") {
const queueCount = Array.isArray(payload.followUp)
? payload.followUp.length
: view.summary.queueCount;
view.summary = { ...view.summary, queueCount };
view.progress = {
...view.progress,
queueCount,
detail: queueCount
? `${queueCount} follow-up${queueCount === 1 ? "" : "s"} queued`
: view.progress.detail,
};
} else if (type === "extension_ui_request")
extensionPresentation(view, payload as Extension);
if (id !== state.selectedRuntimeId && meaningful(type, payload))
view.unread = true;
view.loadedThroughSeq = Math.max(view.loadedThroughSeq, event.seq);
runtimesById[id] = view;
return {
...state,
bridge: { ...state.bridge, latestSeq: event.seq, status: "ready" },
runtimesById,
};
}
export type WorkspaceAction =
| { type: "workspaceLoaded"; workspace: Workspace; persisted?: UiState }
| { type: "hostEvent"; host: HostEvent }
| { type: "snapshotRequested"; runtimeId: string; generation: number }
| {
type: "snapshotLoaded";
runtimeId: string;
generation: number;
snapshot: RuntimeSnapshot;
}
| {
type: "snapshotFailed";
runtimeId: string;
generation: number;
error: string;
}
| { type: "selectRuntime"; runtimeId: string }
| { type: "selectDirectory"; worktreePath: string }
| { type: "draftChanged"; runtimeId: string; draft: string }
| {
type: "scrollChanged";
runtimeId: string;
atBottom: boolean;
scrollTop?: number;
}
| { type: "markRead"; runtimeId: string }
| { type: "setWindowActive"; active: boolean }
| { type: "setScale"; scale: number }
| { type: "setResourceWarning"; value: boolean }
| { type: "dismissCollisionWarning" }
| {
type: "submissionAdded";
runtimeId: string;
submission: RuntimeView["pendingSubmissions"][number];
}
| { type: "submissionSent"; runtimeId: string; id: number }
| { type: "submissionRemoved"; runtimeId: string; id: number };
export function workspaceReducer(
state: WorkspaceState,
action: WorkspaceAction,
): WorkspaceState {
if (action.type === "workspaceLoaded")
return reconcileWorkspace(state, action.workspace, action.persisted);
if (action.type === "hostEvent") {
if (action.host.kind === "event")
return applyWorkspaceEvent(state, action.host.event);
if (action.host.kind === "resetRequired")
return {
...state,
bridge: {
...state.bridge,
latestSeq: 0,
status: "resyncing",
error: action.host.reason,
},
};
if (action.host.kind === "disconnected")
return {
...state,
bridge: {
...state.bridge,
status: "error",
error: action.host.message,
},
};
return {
...state,
bridge: {
instanceId: action.host.bridgeInstanceId,
latestSeq: Math.max(state.bridge.latestSeq, action.host.latestSeq),
status: "ready",
},
};
}
if (action.type === "selectDirectory")
return {
...state,
selectedDirectoryPath: action.worktreePath,
selectedRuntimeId:
state.runtimeOrderByDirectory[action.worktreePath]?.[0],
};
if (action.type === "selectRuntime")
return {
...state,
selectedRuntimeId: action.runtimeId,
selectedDirectoryPath:
state.runtimesById[action.runtimeId]?.summary.worktreePath,
};
if (action.type === "setWindowActive")
return { ...state, windowActive: action.active };
if (action.type === "setScale")
return { ...state, interfaceScale: action.scale };
if (action.type === "setResourceWarning")
return { ...state, resourceWarning: action.value };
if (action.type === "dismissCollisionWarning")
return { ...state, dismissedCollisionWarning: true };
const runtimeId = "runtimeId" in action ? action.runtimeId : undefined;
if (!runtimeId || !state.runtimesById[runtimeId]) return state;
const view = { ...state.runtimesById[runtimeId] };
if (action.type === "snapshotRequested") {
view.loading = true;
view.error = undefined;
view.requestGeneration = action.generation;
} else if (action.type === "snapshotFailed") {
if (view.requestGeneration !== action.generation) return state;
view.loading = false;
view.error = action.error;
} else if (action.type === "snapshotLoaded") {
if (
view.requestGeneration !== action.generation ||
action.snapshot.bridgeInstanceId !== state.bridge.instanceId ||
action.snapshot.latestSeq < view.loadedThroughSeq
)
return state;
view.loading = false;
view.summary = action.snapshot.runtime;
view.state = action.snapshot.state?.data ?? {};
view.stats = action.snapshot.stats?.data ?? {};
view.messages = action.snapshot.transcript?.data?.messages ?? [];
const receivedUserMessages = view.messages
.filter((message) => message.role === "user")
.map(messageText);
view.pendingSubmissions = view.pendingSubmissions.filter(
(pending) => !receivedUserMessages.includes(pending.text),
);
view.commands = action.snapshot.commands?.data?.commands ?? [];
view.models = action.snapshot.models?.data?.models ?? [];
view.extensions = action.snapshot.extensions ?? [];
view.loadedThroughSeq = action.snapshot.latestSeq;
view.progress = view.state.isStreaming
? {
phase: "working",
detail: "Thinking and preparing a response",
toolCount: 0,
}
: idleProgress;
} else if (action.type === "draftChanged") view.draft = action.draft;
else if (action.type === "scrollChanged")
view.scroll = { atBottom: action.atBottom, scrollTop: action.scrollTop };
else if (action.type === "markRead") {
if (
state.selectedRuntimeId === runtimeId &&
state.windowActive &&
view.scroll.atBottom
)
view.unread = false;
} else if (action.type === "submissionAdded")
view.pendingSubmissions = [...view.pendingSubmissions, action.submission];
else if (action.type === "submissionSent")
view.pendingSubmissions = view.pendingSubmissions.map((item) =>
item.id === action.id ? { ...item, phase: "sent" } : item,
);
else if (action.type === "submissionRemoved")
view.pendingSubmissions = view.pendingSubmissions.filter(
(item) => item.id !== action.id,
);
return {
...state,
runtimesById: { ...state.runtimesById, [runtimeId]: view },
};
}
+232
View File
@@ -0,0 +1,232 @@
export type RuntimeError = { code?: string; message?: string };
export type RuntimeSummary = {
runtimeId: string;
worktreePath: string;
state: string;
label: string;
attention: boolean;
queueCount: number;
activeTool?: string;
lastActivity: string;
openedAt: string;
agentId?: string;
sessionId?: string;
sessionPath?: string;
error?: RuntimeError;
};
export type DirectoryWorkspace = {
worktreePath: string;
isHome: boolean;
openCount: number;
workingCount: number;
attentionCount: number;
recoveringCount: number;
errorCount: number;
runtimes: RuntimeSummary[];
};
export type Workspace = {
bridgeInstanceId: string;
latestSeq: number;
directories: DirectoryWorkspace[];
issue?: { code?: string; message?: string };
};
export type WorkspaceSummary = {
bridgeInstanceId: string;
latestSeq: number;
openCount: number;
workingCount: number;
attentionCount: number;
recoveringCount: number;
errorCount: number;
directoryCount: number;
resourceWarning: boolean;
};
export type TodoTask = {
id: number;
subject: string;
activeForm?: string;
status: "pending" | "in_progress" | "completed" | "deleted";
};
export type Message = {
role?: string;
toolName?: string;
details?: { tasks?: TodoTask[] };
content?:
| string
| Array<{ type?: string; text?: string; thinking?: string; name?: string }>;
};
export type AgentState = {
isStreaming?: boolean;
thinkingLevel?: string;
sessionName?: string;
sessionId?: string;
messageCount?: number;
pendingMessageCount?: number;
model?: {
provider?: string;
id?: string;
name?: string;
contextWindow?: number;
};
};
export type SessionStats = {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
contextUsage?: {
tokens?: number | null;
contextWindow?: number;
percent?: number | null;
};
};
export type Command = { name?: string; description?: string };
export type Model = { provider?: string; id?: string; name?: string };
export type Extension = {
id?: string;
method?: string;
title?: string;
message?: string;
options?: string[];
placeholder?: string;
initialValue?: string;
text?: string;
notifyType?: "info" | "warning" | "error";
statusKey?: string;
statusText?: string;
widgetKey?: string;
widgetLines?: string[];
widgetPlacement?: "aboveEditor" | "belowEditor";
};
export type WorkProgress = {
phase: "idle" | "working" | "recovering" | "error";
detail: string;
tool?: string;
queueCount?: number;
toolCount: number;
};
export type PendingSubmission = {
id: number;
text: string;
userMessageCount: number;
phase: "sending" | "sent";
};
export type RuntimeSnapshot = {
bridgeInstanceId: string;
latestSeq: number;
runtime: RuntimeSummary;
state?: { data?: AgentState };
stats?: { data?: SessionStats };
transcript?: { data?: { messages?: Message[] } };
commands?: { data?: { commands?: Command[] } };
models?: { data?: { models?: Model[] } };
extensions: Extension[];
};
export type DirectorySession = {
path: string;
id: string;
cwd: string;
name?: string;
modified: string;
messageCount: number;
firstMessage?: string;
runtimeId?: string;
};
export type WorkspaceEvent = {
bridgeInstanceId: string;
seq: number;
type?: string;
agentId?: string;
data?: {
runtimeId?: string;
worktreePath?: string;
sessionId?: string;
runtime?: RuntimeSummary;
eventType?: string;
eventData?: { state?: string; event?: Record<string, unknown> };
recovery?: Record<string, unknown>;
};
};
export type HostEvent =
| {
kind: "connected";
bridgeInstanceId: string;
firstAvailableSeq: number;
latestSeq: number;
}
| { kind: "event"; event: WorkspaceEvent }
| {
kind: "resetRequired";
bridgeInstanceId: string;
latestSeq: number;
reason: string;
}
| { kind: "disconnected"; message: string; retryInMs: number };
export type RuntimeView = {
summary: RuntimeSummary;
state: AgentState;
stats: SessionStats;
messages: Message[];
commands: Command[];
models: Model[];
extensions: Extension[];
extensionStatuses: Record<string, string>;
extensionWidgets: Record<
string,
{ lines: string[]; placement: "aboveEditor" | "belowEditor" }
>;
notifications: Extension[];
progress: WorkProgress;
pendingSubmissions: PendingSubmission[];
draft: string;
scroll: { atBottom: boolean; scrollTop?: number };
unread: boolean;
loading: boolean;
error?: string;
requestGeneration: number;
loadedThroughSeq: number;
};
export type WorkspaceState = {
bridge: {
instanceId?: string;
latestSeq: number;
status: "connecting" | "ready" | "resyncing" | "error";
error?: string;
};
directoriesByPath: Record<string, DirectoryWorkspace>;
directoryOrder: string[];
runtimesById: Record<string, RuntimeView>;
runtimeOrderByDirectory: Record<string, string[]>;
selectedDirectoryPath?: string;
selectedRuntimeId?: string;
windowActive: boolean;
resourceWarning: boolean;
dismissedCollisionWarning: boolean;
interfaceScale: number;
};
export type UiState = {
version: 1;
directoryOrder: string[];
runtimeOrderByDirectory: Record<string, string[]>;
selectedRuntimeId?: string;
drafts: Record<string, string>;
scroll: Record<string, { atBottom: boolean; scrollTop?: number }>;
lastSeen: Record<
string,
{ bridgeInstanceId: string; seq: number; lastActivity?: string }
>;
workspaceCursor?: { bridgeInstanceId: string; seq: number };
interfaceScale?: number;
dismissedCollisionWarning?: boolean;
};
+147
View File
@@ -0,0 +1,147 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useReducer, useRef } from "react";
import { initialWorkspaceState, workspaceReducer } from "./reducer";
import { legacyPreferences, loadPresentation, presentationState, removeLegacyPreferences, savePresentation } from "./persistence";
import type { HostEvent, RuntimeSnapshot, UiState, Workspace, WorkspaceSummary } from "./types";
export function useWorkspace() {
const [state, dispatch] = useReducer(workspaceReducer, initialWorkspaceState);
const stateRef = useRef(state); stateRef.current = state;
const loadGeneration = useRef<Record<string, number>>({});
const timers = useRef<Record<string, number>>({});
const initialized = useRef(false);
const legacyMigrationPending = useRef(false);
const disposed = useRef(true);
const refreshGeneration = useRef(0);
const loadSnapshot = useCallback(async (runtimeId: string) => {
if (disposed.current) return;
const generation = (loadGeneration.current[runtimeId] ?? 0) + 1;
loadGeneration.current[runtimeId] = generation;
dispatch({ type: "snapshotRequested", runtimeId, generation });
try {
const snapshot = await invoke<RuntimeSnapshot>("get_session_runtime_snapshot", { runtimeId });
if (!disposed.current)
dispatch({ type: "snapshotLoaded", runtimeId, generation, snapshot });
} catch (error) {
if (!disposed.current)
dispatch({ type: "snapshotFailed", runtimeId, generation, error: String(error) });
}
}, []);
const scheduleSnapshot = useCallback((runtimeId: string) => {
if (timers.current[runtimeId]) return;
timers.current[runtimeId] = window.setTimeout(() => {
delete timers.current[runtimeId]; void loadSnapshot(runtimeId);
}, 120);
}, [loadSnapshot]);
const refresh = useCallback(async (initialUiState?: UiState, reloadAllSnapshots = false) => {
const generation = ++refreshGeneration.current;
const cancelled = () => disposed.current || generation !== refreshGeneration.current;
const workspace = await invoke<Workspace>("get_workspace");
if (cancelled()) return;
let nextPersisted = initialUiState;
if (nextPersisted) {
const legacy = legacyPreferences(localStorage);
const candidate = nextPersisted.selectedRuntimeId
? undefined
: workspace.directories.find((directory) => directory.worktreePath === legacy.path)?.runtimes[0];
nextPersisted = {
...nextPersisted,
...(candidate ? { selectedRuntimeId: candidate.runtimeId } : {}),
...(nextPersisted.interfaceScale === undefined && legacy.scale !== undefined
? { interfaceScale: legacy.scale }
: {}),
};
legacyMigrationPending.current = Boolean(
(!initialUiState?.selectedRuntimeId && candidate) ||
(initialUiState?.interfaceScale === undefined && legacy.scale !== undefined),
);
}
dispatch({ type: "workspaceLoaded", workspace, persisted: nextPersisted });
const summary = await invoke<WorkspaceSummary>("get_workspace_summary");
if (cancelled()) return;
dispatch({ type: "setResourceWarning", value: summary.resourceWarning });
const selected = nextPersisted?.selectedRuntimeId && workspace.directories.some((directory) => directory.runtimes.some((runtime) => runtime.runtimeId === nextPersisted?.selectedRuntimeId))
? nextPersisted.selectedRuntimeId : workspace.directories[0]?.runtimes[0]?.runtimeId;
const runtimeIds = workspace.directories.flatMap((directory) => directory.runtimes.map((runtime) => runtime.runtimeId));
for (const runtimeId of reloadAllSnapshots ? runtimeIds : selected ? [selected] : []) {
if (cancelled()) return;
void loadSnapshot(runtimeId);
}
if (cancelled()) return;
await invoke("subscribe_workspace", { cursor: { bridgeInstanceId: workspace.bridgeInstanceId, seq: workspace.latestSeq } });
if (cancelled()) await invoke("unsubscribe_workspace").catch(() => {});
}, [loadSnapshot]);
useEffect(() => {
disposed.current = false;
let stop: (() => void) | undefined;
void listen<HostEvent>("workspace-bridge", (message) => {
if (disposed.current) return;
const host = message.payload;
dispatch({ type: "hostEvent", host });
if (host.kind === "resetRequired") void refresh(undefined, true);
else if (host.kind === "event") {
const runtimeId = host.event.data?.runtimeId;
const eventType = host.event.type;
const piType = String(host.event.data?.eventData?.event?.type ?? "");
if (["runtime_opened", "runtime_closed", "runtime_recovered", "runtime_restore_failed"].includes(eventType ?? "")) {
void refresh();
} else if (runtimeId && (host.event.data?.eventType === "transcript" || piType === "agent_settled" || piType === "compaction_end")) {
scheduleSnapshot(runtimeId);
}
}
}).then((unlisten) => { if (disposed.current) unlisten(); else stop = unlisten; });
void (async () => {
try {
const loaded = await loadPresentation();
if (disposed.current) return;
await refresh(loaded);
if (disposed.current) return;
initialized.current = true;
} catch (error) {
if (!disposed.current)
dispatch({ type: "hostEvent", host: { kind: "disconnected", message: String(error), retryInMs: 0 } });
}
})();
const appWindow = getCurrentWindow();
const visibility = () => dispatch({ type: "setWindowActive", active: document.visibilityState === "visible" && document.hasFocus() });
window.addEventListener("focus", visibility); window.addEventListener("blur", visibility); document.addEventListener("visibilitychange", visibility);
return () => {
disposed.current = true;
refreshGeneration.current += 1;
stop?.();
void invoke("unsubscribe_workspace").catch(() => {});
window.removeEventListener("focus", visibility);
window.removeEventListener("blur", visibility);
document.removeEventListener("visibilitychange", visibility);
Object.values(timers.current).forEach(clearTimeout);
void appWindow;
};
}, [refresh, scheduleSnapshot]);
useEffect(() => {
if (!initialized.current) return;
const timer = window.setTimeout(() => {
void savePresentation(presentationState(state)).then(() => {
if (legacyMigrationPending.current) {
removeLegacyPreferences(localStorage);
legacyMigrationPending.current = false;
}
}).catch(() => {});
}, 300);
return () => clearTimeout(timer);
}, [state.directoryOrder, state.runtimeOrderByDirectory, state.selectedRuntimeId, state.interfaceScale, state.bridge.latestSeq, state.runtimesById]);
useEffect(() => {
const id = state.selectedRuntimeId; if (!id) return;
if (!state.runtimesById[id]?.loading && !state.runtimesById[id]?.messages.length) void loadSnapshot(id);
}, [state.selectedRuntimeId, loadSnapshot]);
return { state, dispatch, refresh, loadSnapshot, scheduleSnapshot };
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()], test: { environment: "jsdom", setupFiles: ["./src/test-setup.ts"] } });