feat(status-ui): enhance desktop session controls
Add native directory selection, persisted interface scaling, and navigation back to the latest conversation message. Restore saved session transcripts when the bridge transcript RPC is unavailable so the desktop client remains useful during bridge compatibility gaps.
This commit is contained in:
+87
-2
@@ -23,6 +23,20 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
|||||||
assert.match(source, /stripAnsi\(extension\.statusText\)/);
|
assert.match(source, /stripAnsi\(extension\.statusText\)/);
|
||||||
assert.match(source, /className="pi-controls"/);
|
assert.match(source, /className="pi-controls"/);
|
||||||
assert.match(source, /aria-label="Model and thinking 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, /<details className="pi-controls">/);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
source,
|
source,
|
||||||
@@ -35,7 +49,7 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
|||||||
);
|
);
|
||||||
assert.match(source, /useLayoutEffect/);
|
assert.match(source, /useLayoutEffect/);
|
||||||
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
|
assert.match(source, /transcript\.scrollTop = transcript\.scrollHeight/);
|
||||||
assert.match(source, /requestAnimationFrame\(scrollToLatest\)/);
|
assert.match(source, /requestAnimationFrame\(scrollTranscriptToLatest\)/);
|
||||||
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
assert.match(source, /const loadGeneration = useRef\(0\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
@@ -55,6 +69,12 @@ test("renders every Pi RPC extension UI method without replying to fire-and-forg
|
|||||||
source,
|
source,
|
||||||
/const lastEventSequence = useRef<Record<string, number>>/,
|
/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, /generation !== loadGeneration\.current/);
|
||||||
assert.match(source, /current\.phase !== "working"/);
|
assert.match(source, /current\.phase !== "working"/);
|
||||||
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
|
assert.match(source, /bridgeEvent\.seq <= \(lastEventSequence\.current/);
|
||||||
@@ -181,6 +201,19 @@ test("shows bridge-managed directory status and sessions for the opened director
|
|||||||
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
|
/state: loadedState\.isStreaming \? "streaming" : "idle"/,
|
||||||
);
|
);
|
||||||
assert.match(component, /className="directory-add"/);
|
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.match(component, /Add directory/);
|
||||||
assert.doesNotMatch(component, /className="folder-form"/);
|
assert.doesNotMatch(component, /className="folder-form"/);
|
||||||
assert.doesNotMatch(component, /Remembered folders/);
|
assert.doesNotMatch(component, /Remembered folders/);
|
||||||
@@ -192,6 +225,9 @@ test("shows bridge-managed directory status and sessions for the opened director
|
|||||||
assert.match(component, /New session/);
|
assert.match(component, /New session/);
|
||||||
assert.match(component, /state\.isStreaming/);
|
assert.match(component, /state\.isStreaming/);
|
||||||
assert.match(styles, /\.directory-add \{/);
|
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 \{/);
|
||||||
assert.match(styles, /\.directory-tab-button \{/);
|
assert.match(styles, /\.directory-tab-button \{/);
|
||||||
assert.match(styles, /\.directory-close \{/);
|
assert.match(styles, /\.directory-close \{/);
|
||||||
@@ -235,6 +271,28 @@ test("shows an optimistic prompt until the transcript receives it", async () =>
|
|||||||
assert.match(styles, /\.pending-message\.sending/);
|
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 () => {
|
test("distinguishes user and assistant messages", async () => {
|
||||||
const styles = await readFile("ui/src/App.css", "utf8");
|
const styles = await readFile("ui/src/App.css", "utf8");
|
||||||
assert.match(styles, /\.message\.user \{/);
|
assert.match(styles, /\.message\.user \{/);
|
||||||
@@ -293,9 +351,36 @@ test("uses a compact workflow layout that keeps the composer and transcript stab
|
|||||||
assert.match(styles, /conic-gradient\(/);
|
assert.match(styles, /conic-gradient\(/);
|
||||||
assert.match(styles, /transcript-border-orbit/);
|
assert.match(styles, /transcript-border-orbit/);
|
||||||
assert.match(styles, /prefers-reduced-motion: reduce/);
|
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(
|
assert.match(
|
||||||
styles,
|
styles,
|
||||||
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 210px/,
|
/\.workflow-main \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) 250px/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.pi-controls \{[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/,
|
||||||
|
);
|
||||||
|
assert.match(styles, /\.scale-settings \{/);
|
||||||
|
assert.match(
|
||||||
|
styles,
|
||||||
|
/\.scale-adjustment \{[\s\S]*grid-template-columns: auto minmax\(0, 1fr\) auto/,
|
||||||
|
);
|
||||||
|
assert.match(styles, /\.scale-adjustment input\[type="range"\] \{/);
|
||||||
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
|
assert.match(styles, /\.pi-controls \{[\s\S]*padding: 4px 6px/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("configures a larger desktop window and enables the folder dialog", async () => {
|
||||||
|
const [config, capability, rust] = await Promise.all([
|
||||||
|
readFile("ui/src-tauri/tauri.conf.json", "utf8"),
|
||||||
|
readFile("ui/src-tauri/capabilities/default.json", "utf8"),
|
||||||
|
readFile("ui/src-tauri/src/lib.rs", "utf8"),
|
||||||
|
]);
|
||||||
|
assert.match(config, /"width": 1100/);
|
||||||
|
assert.match(config, /"height": 840/);
|
||||||
|
assert.match(config, /"minWidth": 640/);
|
||||||
|
assert.match(config, /"minHeight": 520/);
|
||||||
|
assert.match(capability, /"dialog:default"/);
|
||||||
|
assert.match(rust, /\.plugin\(tauri_plugin_dialog::init\(\)\)/);
|
||||||
|
});
|
||||||
|
|||||||
Generated
+13
-2
@@ -1,14 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "ui",
|
"name": "pi-status-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ui",
|
"name": "pi-status-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0"
|
||||||
@@ -1434,6 +1436,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-opener": {
|
"node_modules/@tauri-apps/plugin-opener": {
|
||||||
"version": "2.5.4",
|
"version": "2.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||||
|
|||||||
+6
-5
@@ -17,17 +17,18 @@
|
|||||||
"tauri:release": "tauri build --no-bundle"
|
"tauri:release": "tauri build --no-bundle"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-opener": "^2"
|
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.0.4",
|
"vite": "^7.0.4"
|
||||||
"@tauri-apps/cli": "^2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+84
@@ -2168,6 +2168,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -2383,6 +2384,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-single-instance",
|
"tauri-plugin-single-instance",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -2685,6 +2687,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.3"
|
version = "2.1.3"
|
||||||
@@ -3363,6 +3389,64 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"glob",
|
||||||
|
"plist",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri-utils",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.19",
|
||||||
|
"toml 1.1.3+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-single-instance"
|
name = "tauri-plugin-single-instance"
|
||||||
version = "2.4.3"
|
version = "2.4.3"
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ tauri = { version = "2", features = [] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
|
tokio = { version = "1", features = ["io-util", "net", "rt", "macros"] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-single-instance = "2"
|
tauri-plugin-single-instance = "2"
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": [
|
||||||
|
"main"
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:allow-hide",
|
"core:window:allow-hide",
|
||||||
"core:window:allow-start-dragging"
|
"core:window:allow-start-dragging",
|
||||||
|
"dialog:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::path::Path;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
@@ -132,22 +133,67 @@ pub async fn new_session(socket_path: &str, agent_id: &str) -> Result<Value, Str
|
|||||||
request(socket_path, "new_session", Some(agent_id), None).await
|
request(socket_path, "new_session", Some(agent_id), None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fallback_transcript(state: &Value) -> Value {
|
||||||
|
let Some(path) = state.pointer("/data/sessionFile").and_then(Value::as_str) else {
|
||||||
|
return json!({ "data": { "messages": [] } });
|
||||||
|
};
|
||||||
|
let path = Path::new(path);
|
||||||
|
if !path.is_absolute()
|
||||||
|
|| path.extension().and_then(|extension| extension.to_str()) != Some("jsonl")
|
||||||
|
{
|
||||||
|
return json!({ "data": { "messages": [] } });
|
||||||
|
}
|
||||||
|
let messages = std::fs::read_to_string(path)
|
||||||
|
.ok()
|
||||||
|
.map(|content| {
|
||||||
|
content
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||||
|
.filter(|entry| entry.get("type").and_then(Value::as_str) == Some("message"))
|
||||||
|
.filter_map(|entry| entry.get("message").cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
json!({ "data": { "messages": messages } })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compact_commands(value: Value) -> Value {
|
||||||
|
let commands = value
|
||||||
|
.pointer("/data/commands")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|commands| {
|
||||||
|
commands
|
||||||
|
.iter()
|
||||||
|
.map(|command| {
|
||||||
|
let mut compact = serde_json::Map::new();
|
||||||
|
for field in ["name", "description"] {
|
||||||
|
if let Some(value) = command.get(field) {
|
||||||
|
compact.insert(field.to_owned(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(compact)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
json!({ "data": { "commands": commands } })
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
pub async fn load_agent(socket_path: &str, agent_id: &str) -> Result<Value, String> {
|
||||||
// Older bridge daemons can still serve the conversation while they await a restart.
|
// Older bridge daemons can still serve the conversation while they await a restart.
|
||||||
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
|
let stats = request(socket_path, "get_session_stats", Some(agent_id), None)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| json!({ "data": {} }));
|
.unwrap_or_else(|_| json!({ "data": {} }));
|
||||||
let (state, transcript, commands, models) = tokio::try_join!(
|
let (state, commands, models) = tokio::try_join!(
|
||||||
request(socket_path, "get_state", Some(agent_id), None),
|
request(socket_path, "get_state", Some(agent_id), None),
|
||||||
request(socket_path, "get_transcript", Some(agent_id), None),
|
|
||||||
request(socket_path, "get_commands", Some(agent_id), None),
|
request(socket_path, "get_commands", Some(agent_id), None),
|
||||||
request(socket_path, "get_available_models", Some(agent_id), None),
|
request(socket_path, "get_available_models", Some(agent_id), None),
|
||||||
)?;
|
)?;
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"state": state,
|
"state": state,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"transcript": transcript,
|
"transcript": fallback_transcript(&state),
|
||||||
"commands": commands,
|
"commands": compact_commands(commands),
|
||||||
"models": models
|
"models": models
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -164,7 +210,9 @@ pub async fn submit_prompt(socket_path: &str, agent_id: &str, message: &str) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> {
|
pub async fn abort(socket_path: &str, agent_id: &str) -> Result<(), String> {
|
||||||
request(socket_path, "abort", Some(agent_id), None).await.map(|_| ())
|
request(socket_path, "abort", Some(agent_id), None)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_model(
|
pub async fn set_model(
|
||||||
@@ -313,6 +361,35 @@ mod tests {
|
|||||||
std::fs::remove_file(path).expect("socket cleanup");
|
std::fs::remove_file(path).expect("socket cleanup");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_messages_from_the_saved_session_file_when_transcript_rpc_is_unavailable() {
|
||||||
|
let path = format!(
|
||||||
|
"{}/pi-status-ui-history-{}.jsonl",
|
||||||
|
env::temp_dir().display(),
|
||||||
|
std::process::id()
|
||||||
|
);
|
||||||
|
std::fs::write(
|
||||||
|
&path,
|
||||||
|
concat!(
|
||||||
|
"{\"type\":\"session\",\"id\":\"session-1\"}\n",
|
||||||
|
"{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"Hello\"}}\n",
|
||||||
|
"{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":\"Hi\"}}\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.expect("session fixture");
|
||||||
|
let transcript = fallback_transcript(&json!({
|
||||||
|
"data": { "sessionFile": path }
|
||||||
|
}));
|
||||||
|
assert_eq!(
|
||||||
|
transcript["data"]["messages"],
|
||||||
|
json!([
|
||||||
|
{ "role": "user", "content": "Hello" },
|
||||||
|
{ "role": "assistant", "content": "Hi" }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
std::fs::remove_file(path).expect("session cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn loads_state_history_commands_and_models_as_one_snapshot() {
|
async fn loads_state_history_commands_and_models_as_one_snapshot() {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
@@ -323,7 +400,7 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let listener = UnixListener::bind(&path).expect("listener");
|
let listener = UnixListener::bind(&path).expect("listener");
|
||||||
let server = tokio::spawn(async move {
|
let server = tokio::spawn(async move {
|
||||||
for _ in 0..5 {
|
for _ in 0..4 {
|
||||||
let (stream, _) = listener.accept().await.expect("connection");
|
let (stream, _) = listener.accept().await.expect("connection");
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut line = String::new();
|
let mut line = String::new();
|
||||||
@@ -336,8 +413,11 @@ mod tests {
|
|||||||
Some("get_session_stats") => json!({
|
Some("get_session_stats") => json!({
|
||||||
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
|
"data": { "contextUsage": { "tokens": 32000, "contextWindow": 200000 } }
|
||||||
}),
|
}),
|
||||||
Some("get_transcript") => json!({ "data": { "messages": [] } }),
|
Some("get_commands") => json!({ "data": { "commands": [{
|
||||||
Some("get_commands") => json!({ "data": { "commands": [] } }),
|
"name": "resume",
|
||||||
|
"description": "Resume a saved session",
|
||||||
|
"sourceInfo": { "path": "/very/large/extension/metadata" }
|
||||||
|
}] } }),
|
||||||
Some("get_available_models") => json!({ "data": { "models": [] } }),
|
Some("get_available_models") => json!({ "data": { "models": [] } }),
|
||||||
other => panic!("unexpected operation: {other:?}"),
|
other => panic!("unexpected operation: {other:?}"),
|
||||||
};
|
};
|
||||||
@@ -346,7 +426,11 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
.expect("response JSON");
|
.expect("response JSON");
|
||||||
response.push(b'\n');
|
response.push(b'\n');
|
||||||
reader.get_mut().write_all(&response).await.expect("response");
|
reader
|
||||||
|
.get_mut()
|
||||||
|
.write_all(&response)
|
||||||
|
.await
|
||||||
|
.expect("response");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -354,6 +438,10 @@ mod tests {
|
|||||||
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
|
assert_eq!(snapshot["state"]["data"]["isStreaming"], false);
|
||||||
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
|
assert_eq!(snapshot["stats"]["data"]["contextUsage"]["tokens"], 32000);
|
||||||
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
|
assert_eq!(snapshot["transcript"]["data"]["messages"], json!([]));
|
||||||
|
assert_eq!(
|
||||||
|
snapshot["commands"]["data"]["commands"],
|
||||||
|
json!([{ "name": "resume", "description": "Resume a saved session" }])
|
||||||
|
);
|
||||||
server.await.expect("server succeeds");
|
server.await.expect("server succeeds");
|
||||||
std::fs::remove_file(path).expect("socket cleanup");
|
std::fs::remove_file(path).expect("socket cleanup");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ fn subscribe_agent(
|
|||||||
}
|
}
|
||||||
let event_app = app.clone();
|
let event_app = app.clone();
|
||||||
let task = tauri::async_runtime::spawn(async move {
|
let task = tauri::async_runtime::spawn(async move {
|
||||||
if let Err(error) = bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await {
|
if let Err(error) =
|
||||||
|
bridge::subscribe(socket, agent_id.clone(), cursor, event_app.clone()).await
|
||||||
|
{
|
||||||
let _ = event_app.emit(
|
let _ = event_app.emit(
|
||||||
"bridge-error",
|
"bridge-error",
|
||||||
serde_json::json!({ "agentId": agent_id, "message": error }),
|
serde_json::json!({ "agentId": agent_id, "message": error }),
|
||||||
@@ -141,6 +143,7 @@ fn subscribe_agent(
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let builder = tauri::Builder::default()
|
let builder = tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(Subscription(Mutex::new(None)))
|
.manage(Subscription(Mutex::new(None)))
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -189,6 +192,9 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(window_action(&toggle, true), WindowAction::Hide);
|
assert_eq!(window_action(&toggle, true), WindowAction::Hide);
|
||||||
assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus);
|
assert_eq!(window_action(&toggle, false), WindowAction::ShowAndFocus);
|
||||||
assert_eq!(window_action(&vec!["--show".to_owned()], true), WindowAction::ShowAndFocus);
|
assert_eq!(
|
||||||
|
window_action(&vec!["--show".to_owned()], true),
|
||||||
|
WindowAction::ShowAndFocus
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Pi Status UI",
|
"title": "Pi Status UI",
|
||||||
"width": 900,
|
"width": 1100,
|
||||||
"height": 720,
|
"height": 840,
|
||||||
"minWidth": 480,
|
"minWidth": 640,
|
||||||
"minHeight": 420,
|
"minHeight": 520,
|
||||||
"center": true,
|
"center": true,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"transparent": true,
|
"transparent": true,
|
||||||
|
|||||||
+125
-37
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 18px;
|
||||||
color: #ebebe8;
|
color: #ebebe8;
|
||||||
background: #101111;
|
background: #101111;
|
||||||
}
|
}
|
||||||
@@ -27,8 +28,8 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 480px;
|
min-width: 640px;
|
||||||
min-height: 420px;
|
min-height: 520px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
button,
|
button,
|
||||||
@@ -41,10 +42,10 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
padding: 8px 12px;
|
padding: 9px 14px;
|
||||||
background: #dcecc8;
|
background: #dcecc8;
|
||||||
color: #152012;
|
color: #152012;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
button:hover {
|
button:hover {
|
||||||
@@ -66,8 +67,8 @@ textarea:focus-visible {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 9px;
|
gap: 12px;
|
||||||
padding: 14px;
|
padding: 18px;
|
||||||
border: 1px solid #3b403a;
|
border: 1px solid #3b403a;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(18, 20, 19, 0.97);
|
background: rgba(18, 20, 19, 0.97);
|
||||||
@@ -101,23 +102,23 @@ textarea:focus-visible {
|
|||||||
.brand {
|
.brand {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #b8df84;
|
color: #b8df84;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.status-row {
|
.status-row {
|
||||||
margin-top: 3px;
|
margin-top: 4px;
|
||||||
color: #898f89;
|
color: #898f89;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -139,10 +140,10 @@ h1 {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
border: 1px solid #404740;
|
border: 1px solid #404740;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 2px 6px;
|
padding: 3px 8px;
|
||||||
color: #afb3ad;
|
color: #afb3ad;
|
||||||
background: #202320;
|
background: #202320;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.state-pill.working {
|
.state-pill.working {
|
||||||
@@ -171,8 +172,8 @@ h1 {
|
|||||||
.directory-tab-list {
|
.directory-tab-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 7px;
|
||||||
min-height: 32px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
.directory-tabs {
|
.directory-tabs {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -184,7 +185,7 @@ h1 {
|
|||||||
.agent-caption {
|
.agent-caption {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
color: #70776f;
|
color: #70776f;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -197,16 +198,16 @@ h1 {
|
|||||||
.directory-tab-button {
|
.directory-tab-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 7px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 180px;
|
max-width: 220px;
|
||||||
border: 1px solid #303530;
|
border: 1px solid #303530;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
border-radius: 7px 0 0 7px;
|
border-radius: 7px 0 0 7px;
|
||||||
padding: 5px 8px;
|
padding: 7px 10px;
|
||||||
background: #1b1e1b;
|
background: #1b1e1b;
|
||||||
color: #aeb4ac;
|
color: #aeb4ac;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
.directory-tab-button > span {
|
.directory-tab-button > span {
|
||||||
@@ -216,7 +217,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
.directory-tab-button small {
|
.directory-tab-button small {
|
||||||
color: #70776f;
|
color: #70776f;
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.directory-tab-button.selected {
|
.directory-tab-button.selected {
|
||||||
@@ -236,13 +237,13 @@ h1 {
|
|||||||
border-color: #7da856;
|
border-color: #7da856;
|
||||||
}
|
}
|
||||||
.directory-close {
|
.directory-close {
|
||||||
min-width: 24px;
|
min-width: 30px;
|
||||||
border: 1px solid #303530;
|
border: 1px solid #303530;
|
||||||
border-radius: 0 7px 7px 0;
|
border-radius: 0 7px 7px 0;
|
||||||
padding: 3px 6px;
|
padding: 4px 7px;
|
||||||
background: #1b1e1b;
|
background: #1b1e1b;
|
||||||
color: #aeb4ac;
|
color: #aeb4ac;
|
||||||
font-size: 15px;
|
font-size: 18px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.directory-tab:focus-within .directory-tab-button,
|
.directory-tab:focus-within .directory-tab-button,
|
||||||
@@ -259,7 +260,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
.directory-add-toggle {
|
.directory-add-toggle {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 5px 8px;
|
padding: 7px 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.directory-add {
|
.directory-add {
|
||||||
@@ -269,12 +270,34 @@ h1 {
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
.directory-add input {
|
.directory-add input {
|
||||||
width: 220px;
|
width: 280px;
|
||||||
padding: 5px 7px;
|
padding: 7px 9px;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.directory-add button {
|
.directory-add button {
|
||||||
padding: 5px 8px;
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
.directory-add-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #c4ed8b;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.directory-add-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #52703a;
|
||||||
|
border-top-color: #c4ed8b;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: directory-add-spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes directory-add-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications {
|
.notifications {
|
||||||
@@ -311,9 +334,36 @@ h1 {
|
|||||||
.workflow-main {
|
.workflow-main {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 210px;
|
grid-template-columns: minmax(0, 1fr) 250px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.transcript-container {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.transcript-container .transcript {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.scroll-to-latest {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 14px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #6f8b56;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #263020;
|
||||||
|
color: #dcecc8;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
.scroll-to-latest:hover {
|
||||||
|
background: #35452b;
|
||||||
|
}
|
||||||
.transcript,
|
.transcript,
|
||||||
.settings {
|
.settings {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -499,14 +549,14 @@ h1 {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.38;
|
line-height: 1.38;
|
||||||
color: #d7dbd5;
|
color: #d7dbd5;
|
||||||
}
|
}
|
||||||
.muted {
|
.muted {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #858b85;
|
color: #858b85;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.warning {
|
.warning {
|
||||||
color: #ffb4aa;
|
color: #ffb4aa;
|
||||||
@@ -519,7 +569,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
.pi-controls {
|
.pi-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 150px;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
border: 1px solid #303730;
|
border: 1px solid #303730;
|
||||||
@@ -577,10 +627,10 @@ textarea:focus {
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
textarea {
|
textarea {
|
||||||
min-height: 48px;
|
min-height: 64px;
|
||||||
max-height: 100px;
|
max-height: 140px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
.composer textarea:focus {
|
.composer textarea:focus {
|
||||||
@@ -739,11 +789,49 @@ textarea {
|
|||||||
}
|
}
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
.settings-actions {
|
.settings-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
.scale-settings {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid #303830;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.scale-settings-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.scale-settings-heading output {
|
||||||
|
color: #b8df84;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.scale-adjustment {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.scale-adjustment button {
|
||||||
|
min-width: 32px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.scale-adjustment button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.scale-adjustment input[type="range"] {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
accent-color: #b8df84;
|
||||||
|
}
|
||||||
.session-panel {
|
.session-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -859,7 +947,7 @@ h2 {
|
|||||||
grid-template-columns: minmax(0, 1fr) 170px;
|
grid-template-columns: minmax(0, 1fr) 170px;
|
||||||
}
|
}
|
||||||
.pi-controls {
|
.pi-controls {
|
||||||
grid-template-columns: minmax(0, 1fr) 120px;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
.transcript {
|
.transcript {
|
||||||
padding: 7px 9px;
|
padding: 7px 9px;
|
||||||
|
|||||||
+184
-17
@@ -9,6 +9,7 @@ import {
|
|||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
type Agent = { id: string; worktreePath: string; state: string };
|
type Agent = { id: string; worktreePath: string; state: string };
|
||||||
@@ -131,6 +132,10 @@ const thinkingLevels = [
|
|||||||
"max",
|
"max",
|
||||||
];
|
];
|
||||||
const selectedPathKey = "pi-status-ui.selected-worktree";
|
const selectedPathKey = "pi-status-ui.selected-worktree";
|
||||||
|
const uiScaleKey = "pi-status-ui.interface-scale";
|
||||||
|
const minimumUiScale = 0.9;
|
||||||
|
const maximumUiScale = 1.4;
|
||||||
|
const uiScaleStep = 0.05;
|
||||||
const controlCommands: Command[] = [
|
const controlCommands: Command[] = [
|
||||||
{
|
{
|
||||||
name: "model",
|
name: "model",
|
||||||
@@ -181,6 +186,10 @@ function formatTokens(value?: number | null) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampUiScale(value: number) {
|
||||||
|
return Math.min(maximumUiScale, Math.max(minimumUiScale, value));
|
||||||
|
}
|
||||||
|
|
||||||
function currentTodos(messages: Message[]) {
|
function currentTodos(messages: Message[]) {
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
@@ -221,14 +230,21 @@ function App() {
|
|||||||
const [state, setState] = useState<AgentState>({});
|
const [state, setState] = useState<AgentState>({});
|
||||||
const [stats, setStats] = useState<SessionStats>({});
|
const [stats, setStats] = useState<SessionStats>({});
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true);
|
||||||
const [pendingSubmissions, setPendingSubmissions] = useState<
|
const [pendingSubmissions, setPendingSubmissions] = useState<
|
||||||
PendingSubmission[]
|
PendingSubmission[]
|
||||||
>([]);
|
>([]);
|
||||||
const [models, setModels] = useState<Model[]>([]);
|
const [models, setModels] = useState<Model[]>([]);
|
||||||
const [commands, setCommands] = useState<Command[]>([]);
|
const [commands, setCommands] = useState<Command[]>([]);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [uiScale, setUiScale] = useState(() => {
|
||||||
|
const storedScale = Number(localStorage.getItem(uiScaleKey));
|
||||||
|
return Number.isFinite(storedScale) ? clampUiScale(storedScale) : 1;
|
||||||
|
});
|
||||||
const [folderPath, setFolderPath] = useState("");
|
const [folderPath, setFolderPath] = useState("");
|
||||||
const [addingDirectory, setAddingDirectory] = useState(false);
|
const [addingDirectory, setAddingDirectory] = useState(false);
|
||||||
|
const [isAddingDirectory, setIsAddingDirectory] = useState(false);
|
||||||
|
const [pendingDirectoryPath, setPendingDirectoryPath] = useState<string>();
|
||||||
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
|
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
|
||||||
const [view, setView] = useState<"conversation" | "settings">("conversation");
|
const [view, setView] = useState<"conversation" | "settings">("conversation");
|
||||||
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
|
const [commandFollowUp, setCommandFollowUp] = useState<FollowUpCommand>();
|
||||||
@@ -253,6 +269,8 @@ function App() {
|
|||||||
const bridgeRetryTimer = useRef<number | undefined>(undefined);
|
const bridgeRetryTimer = useRef<number | undefined>(undefined);
|
||||||
const bridgeRetryAttempt = useRef(0);
|
const bridgeRetryAttempt = useRef(0);
|
||||||
const lastEventSequence = useRef<Record<string, number>>({});
|
const lastEventSequence = useRef<Record<string, number>>({});
|
||||||
|
const eventReloadInFlight = useRef(false);
|
||||||
|
const queuedEventReload = useRef<string | undefined>(undefined);
|
||||||
const [workProgress, setWorkProgress] = useState<WorkProgress>({
|
const [workProgress, setWorkProgress] = useState<WorkProgress>({
|
||||||
phase: "idle",
|
phase: "idle",
|
||||||
detail: "Ready for your next prompt",
|
detail: "Ready for your next prompt",
|
||||||
@@ -338,6 +356,20 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadAgentAfterEvent(agentId: string) {
|
||||||
|
if (eventReloadInFlight.current) {
|
||||||
|
queuedEventReload.current = agentId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eventReloadInFlight.current = true;
|
||||||
|
void loadAgent(agentId).finally(() => {
|
||||||
|
eventReloadInFlight.current = false;
|
||||||
|
const queuedAgentId = queuedEventReload.current;
|
||||||
|
queuedEventReload.current = undefined;
|
||||||
|
if (queuedAgentId) reloadAgentAfterEvent(queuedAgentId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleBridgeReconnect(preferredPath?: string) {
|
function scheduleBridgeReconnect(preferredPath?: string) {
|
||||||
if (bridgeRetryTimer.current !== undefined) return;
|
if (bridgeRetryTimer.current !== undefined) return;
|
||||||
const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000);
|
const delay = Math.min(250 * 2 ** bridgeRetryAttempt.current, 2_000);
|
||||||
@@ -402,20 +434,55 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!pendingDirectoryPath ||
|
||||||
|
!directories.some(
|
||||||
|
(directory) => directory.worktreePath === pendingDirectoryPath,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
setPendingDirectoryPath(undefined);
|
||||||
|
setIsAddingDirectory(false);
|
||||||
|
setAddingDirectory(false);
|
||||||
|
setStatus(`Added ${worktreeLabel(pendingDirectoryPath)}`);
|
||||||
|
}, [directories, pendingDirectoryPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId) void loadSessions(selectedId);
|
if (selectedId) void loadSessions(selectedId);
|
||||||
else setSessions([]);
|
else setSessions([]);
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
function updateTranscriptScrollPosition() {
|
||||||
const scrollToLatest = () => {
|
|
||||||
const transcript = transcriptRef.current;
|
const transcript = transcriptRef.current;
|
||||||
if (transcript) transcript.scrollTop = transcript.scrollHeight;
|
if (!transcript) return;
|
||||||
};
|
const isAtBottom =
|
||||||
scrollToLatest();
|
transcript.scrollHeight -
|
||||||
const frame = requestAnimationFrame(scrollToLatest);
|
transcript.scrollTop -
|
||||||
|
transcript.clientHeight <=
|
||||||
|
4;
|
||||||
|
setIsTranscriptAtBottom((current) =>
|
||||||
|
current === isAtBottom ? current : isAtBottom,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollTranscriptToLatest() {
|
||||||
|
const transcript = transcriptRef.current;
|
||||||
|
if (!transcript) return;
|
||||||
|
transcript.scrollTop = transcript.scrollHeight;
|
||||||
|
setIsTranscriptAtBottom(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setIsTranscriptAtBottom(true);
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!isTranscriptAtBottom) return;
|
||||||
|
scrollTranscriptToLatest();
|
||||||
|
const frame = requestAnimationFrame(scrollTranscriptToLatest);
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [messages, pendingSubmissions, selectedId]);
|
}, [isTranscriptAtBottom, messages, pendingSubmissions, selectedId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userMessages = messages.filter((entry) => entry.role === "user");
|
const userMessages = messages.filter((entry) => entry.role === "user");
|
||||||
@@ -579,6 +646,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let unlistenError: (() => void) | undefined;
|
let unlistenError: (() => void) | undefined;
|
||||||
void listen<BridgeEvent>("bridge-event", (event) => {
|
void listen<BridgeEvent>("bridge-event", (event) => {
|
||||||
@@ -608,10 +676,11 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
updateWorkProgress(bridgeEvent);
|
updateWorkProgress(bridgeEvent);
|
||||||
if (selectedId) void loadAgent(selectedId);
|
if (selectedId) void reloadAgentAfterEvent(selectedId);
|
||||||
}
|
}
|
||||||
}).then((stop) => {
|
}).then((stop) => {
|
||||||
unlisten = stop;
|
if (disposed) stop();
|
||||||
|
else unlisten = stop;
|
||||||
});
|
});
|
||||||
void listen<{ message?: string }>("bridge-error", (event) => {
|
void listen<{ message?: string }>("bridge-error", (event) => {
|
||||||
setStatus(
|
setStatus(
|
||||||
@@ -619,9 +688,11 @@ function App() {
|
|||||||
);
|
);
|
||||||
scheduleBridgeReconnect();
|
scheduleBridgeReconnect();
|
||||||
}).then((stop) => {
|
}).then((stop) => {
|
||||||
unlistenError = stop;
|
if (disposed) stop();
|
||||||
|
else unlistenError = stop;
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
disposed = true;
|
||||||
unlisten?.();
|
unlisten?.();
|
||||||
unlistenError?.();
|
unlistenError?.();
|
||||||
};
|
};
|
||||||
@@ -783,28 +854,54 @@ function App() {
|
|||||||
setCommandFollowUp(undefined);
|
setCommandFollowUp(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUiScale(nextScale: number) {
|
||||||
|
const scale = clampUiScale(nextScale);
|
||||||
|
setUiScale(scale);
|
||||||
|
localStorage.setItem(uiScaleKey, String(scale));
|
||||||
|
}
|
||||||
|
|
||||||
async function activateWorktree(worktreePath: string) {
|
async function activateWorktree(worktreePath: string) {
|
||||||
|
setIsAddingDirectory(true);
|
||||||
|
setPendingDirectoryPath(worktreePath);
|
||||||
|
setStatus("Adding directory…");
|
||||||
try {
|
try {
|
||||||
const result = await invoke<{ agent: Agent }>("select_worktree", {
|
const result = await invoke<{ agent: Agent }>("select_worktree", {
|
||||||
worktreePath,
|
worktreePath,
|
||||||
});
|
});
|
||||||
localStorage.setItem(selectedPathKey, worktreePath);
|
const addedPath = result.agent.worktreePath;
|
||||||
|
localStorage.setItem(selectedPathKey, addedPath);
|
||||||
|
setPendingDirectoryPath(addedPath);
|
||||||
setFolderPath("");
|
setFolderPath("");
|
||||||
setAddingDirectory(false);
|
|
||||||
setSelectedId(result.agent.id);
|
setSelectedId(result.agent.id);
|
||||||
await refreshAgents(worktreePath);
|
await refreshAgents(addedPath);
|
||||||
setView("conversation");
|
setView("conversation");
|
||||||
|
} catch (error) {
|
||||||
|
setPendingDirectoryPath(undefined);
|
||||||
|
setIsAddingDirectory(false);
|
||||||
|
setStatus(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function browseForFolder() {
|
||||||
|
try {
|
||||||
|
const selectedPath = await open({
|
||||||
|
directory: true,
|
||||||
|
multiple: false,
|
||||||
|
defaultPath: folderPath || undefined,
|
||||||
|
});
|
||||||
|
if (selectedPath) setFolderPath(selectedPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(String(error));
|
setStatus(String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFolder() {
|
async function addFolder() {
|
||||||
if (!folderPath.startsWith("/")) {
|
const worktreePath = folderPath.trim();
|
||||||
|
if (!worktreePath.startsWith("/")) {
|
||||||
setStatus("Enter an absolute folder path");
|
setStatus("Enter an absolute folder path");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await activateWorktree(folderPath);
|
await activateWorktree(worktreePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -940,7 +1037,7 @@ function App() {
|
|||||||
const workspaceLabel =
|
const workspaceLabel =
|
||||||
extensionTitle ?? worktreeLabel(selected?.worktreePath) ?? "Pi";
|
extensionTitle ?? worktreeLabel(selected?.worktreePath) ?? "Pi";
|
||||||
return (
|
return (
|
||||||
<main className="app-shell">
|
<main className="app-shell" style={{ zoom: uiScale }}>
|
||||||
<header
|
<header
|
||||||
className="workflow-header"
|
className="workflow-header"
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
@@ -1069,12 +1166,14 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
aria-expanded={addingDirectory}
|
aria-expanded={addingDirectory}
|
||||||
className="quiet directory-add-toggle"
|
className="quiet directory-add-toggle"
|
||||||
|
disabled={isAddingDirectory}
|
||||||
onClick={() => setAddingDirectory((current) => !current)}
|
onClick={() => setAddingDirectory((current) => !current)}
|
||||||
>
|
>
|
||||||
{addingDirectory ? "Cancel" : "Add directory"}
|
{addingDirectory ? "Cancel" : "Add directory"}
|
||||||
</button>
|
</button>
|
||||||
{addingDirectory && (
|
{addingDirectory && (
|
||||||
<form
|
<form
|
||||||
|
aria-busy={isAddingDirectory}
|
||||||
className="directory-add"
|
className="directory-add"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1083,12 +1182,28 @@ function App() {
|
|||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
disabled={isAddingDirectory}
|
||||||
value={folderPath}
|
value={folderPath}
|
||||||
onChange={(event) => setFolderPath(event.currentTarget.value)}
|
onChange={(event) => setFolderPath(event.currentTarget.value)}
|
||||||
placeholder="/absolute/path/to/project"
|
placeholder="/absolute/path/to/project"
|
||||||
aria-label="Directory path"
|
aria-label="Directory path"
|
||||||
/>
|
/>
|
||||||
<button type="submit">Add</button>
|
<button
|
||||||
|
disabled={isAddingDirectory}
|
||||||
|
onClick={() => void browseForFolder()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Browse…
|
||||||
|
</button>
|
||||||
|
<button disabled={isAddingDirectory} type="submit">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
{isAddingDirectory && (
|
||||||
|
<span className="directory-add-status" role="status">
|
||||||
|
<span aria-hidden="true" className="directory-add-spinner" />
|
||||||
|
Adding directory…
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -1120,6 +1235,44 @@ function App() {
|
|||||||
{state.messageCount ?? 0} messages ·{" "}
|
{state.messageCount ?? 0} messages ·{" "}
|
||||||
{state.pendingMessageCount ?? 0} queued
|
{state.pendingMessageCount ?? 0} queued
|
||||||
</p>
|
</p>
|
||||||
|
<section
|
||||||
|
aria-labelledby="interface-scale-heading"
|
||||||
|
className="scale-settings"
|
||||||
|
>
|
||||||
|
<div className="scale-settings-heading">
|
||||||
|
<h2 id="interface-scale-heading">Interface scale</h2>
|
||||||
|
<output aria-live="polite">{Math.round(uiScale * 100)}%</output>
|
||||||
|
</div>
|
||||||
|
<div className="scale-adjustment">
|
||||||
|
<button
|
||||||
|
aria-label="Decrease interface scale"
|
||||||
|
disabled={uiScale <= minimumUiScale}
|
||||||
|
onClick={() => updateUiScale(uiScale - uiScaleStep)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
aria-label="Interface scale"
|
||||||
|
max={maximumUiScale}
|
||||||
|
min={minimumUiScale}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateUiScale(Number(event.currentTarget.value))
|
||||||
|
}
|
||||||
|
step={uiScaleStep}
|
||||||
|
type="range"
|
||||||
|
value={uiScale}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label="Increase interface scale"
|
||||||
|
disabled={uiScale >= maximumUiScale}
|
||||||
|
onClick={() => updateUiScale(uiScale + uiScaleStep)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section
|
<section
|
||||||
className="session-panel"
|
className="session-panel"
|
||||||
aria-label="Sessions for selected directory"
|
aria-label="Sessions for selected directory"
|
||||||
@@ -1201,9 +1354,11 @@ function App() {
|
|||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
>
|
>
|
||||||
<div className="workflow-main">
|
<div className="workflow-main">
|
||||||
|
<div className="transcript-container">
|
||||||
<section
|
<section
|
||||||
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
|
className={`transcript ${pendingExtension ? "with-extension" : ""} ${workProgress.phase}`}
|
||||||
aria-label="Conversation"
|
aria-label="Conversation"
|
||||||
|
onScroll={updateTranscriptScrollPosition}
|
||||||
ref={transcriptRef}
|
ref={transcriptRef}
|
||||||
>
|
>
|
||||||
{messages.length ? (
|
{messages.length ? (
|
||||||
@@ -1236,6 +1391,18 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
|
{!isTranscriptAtBottom && (
|
||||||
|
<button
|
||||||
|
aria-label="Scroll to latest message"
|
||||||
|
className="scroll-to-latest"
|
||||||
|
onClick={scrollTranscriptToLatest}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↓</span>
|
||||||
|
<span className="sr-only">Scroll to latest message</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<aside className="todos-pane" aria-label="Current todos">
|
<aside className="todos-pane" aria-label="Current todos">
|
||||||
<div className="todos-heading">
|
<div className="todos-heading">
|
||||||
<strong>Todos</strong>
|
<strong>Todos</strong>
|
||||||
|
|||||||
Reference in New Issue
Block a user