feat(rpc): add validated desktop Pi RPC command palette
This commit is contained in:
+144
-20
@@ -6,6 +6,8 @@ import "./App.css";
|
||||
import { ConversationWorkspace } from "./components/ConversationWorkspace";
|
||||
import { DirectorySidebar } from "./components/DirectorySidebar";
|
||||
import { ExtensionDialog } from "./components/ExtensionDialog";
|
||||
import { CommandFormDialog } from "./components/CommandFormDialog";
|
||||
import type { RpcCommand } from "./commands/rpc";
|
||||
import { SessionPicker } from "./components/SessionPicker";
|
||||
import { SessionTabs } from "./components/SessionTabs";
|
||||
import type {
|
||||
@@ -37,6 +39,7 @@ type LocalOperation = {
|
||||
export default function App() {
|
||||
const { state, dispatch, refresh, loadSnapshot } = useWorkspace();
|
||||
const [status, setStatus] = useState("Connecting to Pi Status Bridge…");
|
||||
const [commandForm, setCommandForm] = useState<RpcCommand | undefined>();
|
||||
const [view, setView] = useState<"conversation" | "settings">("conversation");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
@@ -64,6 +67,7 @@ export default function App() {
|
||||
const mountedRef = useRef(true);
|
||||
const operationSequence = useRef(0);
|
||||
const [operation, setOperation] = useState<LocalOperation | undefined>();
|
||||
const [abortingRuntimeId, setAbortingRuntimeId] = useState<string>();
|
||||
const [restoreTabFocus, setRestoreTabFocus] = useState(false);
|
||||
const startOperation = (
|
||||
kind: LocalOperation["kind"],
|
||||
@@ -99,6 +103,14 @@ export default function App() {
|
||||
);
|
||||
}, [selected, dispatch, operation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
abortingRuntimeId &&
|
||||
state.runtimesById[abortingRuntimeId]?.summary.state !== "streaming"
|
||||
)
|
||||
setAbortingRuntimeId(undefined);
|
||||
}, [abortingRuntimeId, state.runtimesById]);
|
||||
|
||||
async function createRuntime(path = state.selectedDirectoryPath) {
|
||||
if (!path || !mountedRef.current) return;
|
||||
const operationId = startOperation(
|
||||
@@ -292,9 +304,13 @@ export default function App() {
|
||||
}
|
||||
const runtimeId = selected.summary.runtimeId,
|
||||
id = ++submissionId,
|
||||
initialDelivery =
|
||||
selected.summary.state === "streaming" ? "follow_up" : "prompt",
|
||||
operationId = startOperation(
|
||||
"submitting",
|
||||
"Sending prompt to Pi…",
|
||||
initialDelivery === "follow_up"
|
||||
? "Queueing follow-up…"
|
||||
: "Sending prompt to Pi…",
|
||||
selected.summary.runtimeId,
|
||||
);
|
||||
dispatch({
|
||||
@@ -307,24 +323,49 @@ export default function App() {
|
||||
(message) => message.role === "user",
|
||||
).length,
|
||||
phase: "sending",
|
||||
delivery: initialDelivery,
|
||||
},
|
||||
});
|
||||
dispatch({ type: "draftChanged", runtimeId, draft: "" });
|
||||
const sent = await agentCommand(
|
||||
"submit_prompt",
|
||||
{ message: text },
|
||||
false,
|
||||
selected,
|
||||
false,
|
||||
);
|
||||
if (sent) {
|
||||
dispatch({ type: "submissionSent", runtimeId, id });
|
||||
setStatus("Prompt accepted · waiting for Pi…");
|
||||
} else {
|
||||
try {
|
||||
const result = await invoke<{ delivery?: "prompt" | "follow_up" }>(
|
||||
"submit_prompt",
|
||||
{ agentId: selected.summary.agentId, message: text },
|
||||
);
|
||||
const delivery = result.delivery ?? initialDelivery;
|
||||
dispatch({ type: "submissionSent", runtimeId, id, delivery });
|
||||
setStatus(
|
||||
delivery === "follow_up"
|
||||
? "Follow-up queued · waiting for Pi…"
|
||||
: "Prompt accepted · waiting for Pi…",
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(`Pi command failed: ${String(error)}`);
|
||||
dispatch({ type: "submissionRemoved", runtimeId, id });
|
||||
dispatch({ type: "draftChanged", runtimeId, draft: text });
|
||||
} finally {
|
||||
finishOperation(operationId);
|
||||
}
|
||||
finishOperation(operationId);
|
||||
}
|
||||
async function abortSelected() {
|
||||
const target = selected;
|
||||
if (!target?.summary.agentId) return;
|
||||
setAbortingRuntimeId(target.summary.runtimeId);
|
||||
setStatus("Aborting Pi…");
|
||||
try {
|
||||
await invoke("abort", { agentId: target.summary.agentId });
|
||||
} catch (error) {
|
||||
setStatus(`Pi command failed: ${String(error)}`);
|
||||
setAbortingRuntimeId(undefined);
|
||||
}
|
||||
}
|
||||
async function runRpcCommand(
|
||||
command: RpcCommand,
|
||||
input: Record<string, unknown> = {},
|
||||
) {
|
||||
if (!selected) return;
|
||||
await agentCommand("pi_rpc_command", { command: command.command, input });
|
||||
setCommandForm(undefined);
|
||||
}
|
||||
async function respond(response: Record<string, unknown>) {
|
||||
const target = selected;
|
||||
@@ -342,10 +383,15 @@ export default function App() {
|
||||
const pendingSubmission = pendingSubmissions[pendingSubmissions.length - 1];
|
||||
const displayedStatus =
|
||||
operation?.detail ??
|
||||
state.newSessionLaunch?.detail ??
|
||||
(pendingSubmission
|
||||
? pendingSubmission.phase === "sending"
|
||||
? "Sending prompt to Pi…"
|
||||
: "Prompt accepted · waiting for Pi…"
|
||||
? pendingSubmission.delivery === "follow_up"
|
||||
? "Queueing follow-up…"
|
||||
: "Sending prompt to Pi…"
|
||||
: pendingSubmission.delivery === "follow_up"
|
||||
? "Follow-up queued · waiting for Pi…"
|
||||
: "Prompt accepted · waiting for Pi…"
|
||||
: undefined) ??
|
||||
(selected?.progress.phase === "working" ||
|
||||
selected?.progress.phase === "recovering"
|
||||
@@ -353,11 +399,19 @@ export default function App() {
|
||||
: undefined) ??
|
||||
status;
|
||||
const isWorking =
|
||||
state.newSessionLaunch?.phase === "starting" ||
|
||||
selected?.progress.phase === "working" ||
|
||||
selected?.progress.phase === "recovering";
|
||||
const isStartingSession =
|
||||
state.newSessionLaunch?.phase === "starting" ||
|
||||
operation?.kind === "creating";
|
||||
const sameDirectoryCollision = directory && directory.openCount > 1;
|
||||
return (
|
||||
<main className="app-shell" style={{ zoom: state.interfaceScale }}>
|
||||
<main
|
||||
className="app-shell"
|
||||
style={{ zoom: state.interfaceScale }}
|
||||
aria-busy={isStartingSession}
|
||||
>
|
||||
<header
|
||||
className="workflow-header"
|
||||
onMouseDown={(event) => {
|
||||
@@ -379,15 +433,21 @@ export default function App() {
|
||||
{isWorking && (
|
||||
<span className="working-pulse" aria-hidden="true" />
|
||||
)}
|
||||
{isWorking ? "Working" : selected.summary.state}
|
||||
{state.newSessionLaunch?.phase === "starting"
|
||||
? "Starting"
|
||||
: isWorking
|
||||
? "Working"
|
||||
: selected.summary.state}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="status-row">
|
||||
{isWorking && (
|
||||
<span className="working-banner" role="status">
|
||||
<span className="working-pulse" aria-hidden="true" /> Pi is
|
||||
working
|
||||
<span className="working-pulse" aria-hidden="true" />{" "}
|
||||
{state.newSessionLaunch?.phase === "starting"
|
||||
? "Starting new session"
|
||||
: "Pi is working"}
|
||||
</span>
|
||||
)}
|
||||
<span role="status" aria-live="polite">
|
||||
@@ -503,6 +563,29 @@ export default function App() {
|
||||
{view === "settings" ? (
|
||||
<section className="settings">
|
||||
<h2>Session controls</h2>
|
||||
<label className="default-worktree-control">
|
||||
Default new-session directory
|
||||
<select
|
||||
value={state.defaultNewSessionWorktree ?? ""}
|
||||
onChange={(event) =>
|
||||
dispatch({
|
||||
type: "setDefaultNewSessionWorktree",
|
||||
worktreePath: event.currentTarget.value || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Use system default</option>
|
||||
{state.directoryOrder.map((path) => (
|
||||
<option key={path} value={path}>
|
||||
{path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>
|
||||
Used by Ctrl+Super+Space. Only currently open directories are
|
||||
listed.
|
||||
</small>
|
||||
</label>
|
||||
{selected ? (
|
||||
<>
|
||||
<p>{selected.summary.worktreePath}</p>
|
||||
@@ -553,7 +636,7 @@ export default function App() {
|
||||
})
|
||||
}
|
||||
onSubmit={() => void submit()}
|
||||
onAbort={() => void agentCommand("abort")}
|
||||
onAbort={() => void abortSelected()}
|
||||
onScroll={(atBottom, scrollTop) => {
|
||||
dispatch({
|
||||
type: "scrollChanged",
|
||||
@@ -582,6 +665,12 @@ export default function App() {
|
||||
operation?.kind === "submitting" &&
|
||||
operation.runtimeId === selected.summary.runtimeId
|
||||
}
|
||||
isAborting={abortingRuntimeId === selected.summary.runtimeId}
|
||||
onCommand={(command) =>
|
||||
command.input?.length
|
||||
? setCommandForm(command)
|
||||
: void runRpcCommand(command)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<section className="empty-workspace">
|
||||
@@ -605,10 +694,45 @@ export default function App() {
|
||||
onClose={closePicker}
|
||||
/>
|
||||
)}
|
||||
<CommandFormDialog
|
||||
command={commandForm}
|
||||
onCancel={() => setCommandForm(undefined)}
|
||||
onSubmit={(input) =>
|
||||
commandForm && void runRpcCommand(commandForm, input)
|
||||
}
|
||||
/>
|
||||
<ExtensionDialog
|
||||
extension={selected?.extensions[0]}
|
||||
onRespond={(response) => void respond(response)}
|
||||
/>
|
||||
{isStartingSession && (
|
||||
<div
|
||||
className="startup-blocker"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="startup-blocker-title"
|
||||
tabIndex={-1}
|
||||
autoFocus
|
||||
>
|
||||
<div className="startup-blocker-card">
|
||||
<span className="working-bars startup-bars" aria-hidden="true">
|
||||
<i />
|
||||
<i />
|
||||
<i />
|
||||
</span>
|
||||
<div>
|
||||
<strong id="startup-blocker-title">Starting new session</strong>
|
||||
<p>
|
||||
{state.newSessionLaunch?.detail ??
|
||||
"Creating a fresh Pi runtime…"}
|
||||
</p>
|
||||
<small>
|
||||
Pi is loading its session and extensions. Please wait.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user