{loadError &&
{loadError}
}
+ {!activeId && (
+
+
+ {lastConnected && (
+
+ Last connected {new Date(lastConnected).toLocaleTimeString()}
+
+ )}
+ {diagnostics && (
+
+ Pairing accepted · {diagnostics.agentReady ? "Pipper ready" : "Pipper starting"} ·{" "}
+ {diagnostics.availableAgents} available agents · {diagnostics.projects} projects
+
+ )}
+ {diagnostics?.availableAgents === 0 && (
+ Install and select an agent in Pipper on your laptop.
+ )}
+ {diagnostics?.projects === 0 && (
+ Add a Git project in Pipper on your laptop.
+ )}
+
+
+ )}
{!activeId ? (
<>
@@ -567,11 +669,17 @@ export function RemoteApp() {
) : (
<>
{reportError && (
-
Couldn't refresh — showing last update.
+
+ Couldn't refresh — showing the last update. Keep Pipper open on your laptop.
+
)}
- {report.running ? "Running on laptop…" : "Done"}
+ {report.permissions?.length
+ ? "Needs your input"
+ : report.running
+ ? "Running on laptop…"
+ : "Not running"}
{report.projectName ? ` · ${report.projectName}` : ""}
{report.summary && {report.summary}
}
@@ -592,6 +700,44 @@ export function RemoteApp() {
)}
+ {lastUpdated && (
+
+ Updated {new Date(lastUpdated).toLocaleTimeString()}
+
+ )}
+ {report.request?.error &&
{report.request.error}
}
+ {report.permissions?.map((decision) => (
+
+ {decision.title}
+ {decision.detail && {decision.detail}}
+ {decision.options.map((option) => (
+
+ ))}
+
+
+ ))}
+ {canRun && (report.running || !!report.permissions?.length) && (
+
+ )}
{report.messages.length === 0 && !pendingVisible && (
Waiting for the first reply…
)}
@@ -627,7 +773,7 @@ export function RemoteApp() {
)}
{!report.running && !report.finalText && report.messages.length > 0 && (
-
Done
+
Not running
)}
>
@@ -680,7 +826,7 @@ export function RemoteApp() {
className="composer-send"
onClick={() => void send()}
aria-label="Send"
- disabled={!canRun || !draft.trim() || sending || attaching}
+ disabled={!canRun || !draft.trim() || sending || attaching || report?.running === true}
whileTap={{ scale: 0.9 }}
transition={{ duration: 0.08 }}
>
diff --git a/src/remote/api.ts b/src/remote/api.ts
index 0d818de..22dc26a 100644
--- a/src/remote/api.ts
+++ b/src/remote/api.ts
@@ -72,15 +72,27 @@ export function forgetCurrentLaptop(): void {
if (laptop) forgetLaptop(storage(), laptop.id);
}
+/** A failed API call; `retryable` means the laptop confirmed nothing was started. */
+export class RemoteApiError extends Error {
+ readonly retryable: boolean;
+ constructor(message: string, retryable = false) {
+ super(message);
+ this.retryable = retryable;
+ }
+}
+
async function failure(path: string, res: Response): Promise
{
const text = await res.text().catch(() => "");
let message = text.slice(0, 200);
+ let retryable = false;
try {
- message = (JSON.parse(text) as { error?: string }).error ?? message;
+ const body = JSON.parse(text) as { error?: string; retryable?: boolean };
+ message = body.error ?? message;
+ retryable = body.retryable === true;
} catch {
// not JSON
}
- return new Error(`${path} → ${res.status} ${message}`);
+ return new RemoteApiError(`${path} → ${res.status} ${message}`, retryable);
}
/** Call the active laptop's API (same origin, or cross-origin when hosted). */
diff --git a/src/remote/remote.css b/src/remote/remote.css
index fd7dc6f..ba77cd5 100644
--- a/src/remote/remote.css
+++ b/src/remote/remote.css
@@ -599,3 +599,20 @@ body {
.notice svg {
vertical-align: -3px;
}
+
+.remote-decision {
+ display: grid;
+ gap: 8px;
+ padding: 16px;
+ border: 1px solid var(--ink-faint);
+ border-radius: var(--radius-card);
+}
+.remote-decision pre {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-size: 12px;
+}
+button:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
diff --git a/src/remote/submissions.test.ts b/src/remote/submissions.test.ts
new file mode 100644
index 0000000..b76e1bb
--- /dev/null
+++ b/src/remote/submissions.test.ts
@@ -0,0 +1,18 @@
+import { expect, it } from "vitest";
+import { acknowledgeSubmission, submissionId } from "./submissions.ts";
+
+it("reuses uncertain submissions across reloads and only clears the acknowledged request", () => {
+ const data = new Map();
+ const storage = {
+ getItem: (key: string) => data.get(key) ?? null,
+ setItem: (key: string, value: string) => data.set(key, value),
+ } as unknown as Storage;
+ const scope = ["create", "project", "agent", "Fix login"];
+ const first = submissionId(storage, scope);
+ expect(submissionId(storage, scope)).toBe(first);
+ expect(submissionId(storage, ["prompt", "thread", "Fix login"])).not.toBe(first);
+ acknowledgeSubmission(storage, scope, "wrong-id");
+ expect(submissionId(storage, scope)).toBe(first);
+ acknowledgeSubmission(storage, scope, first);
+ expect(submissionId(storage, scope)).not.toBe(first);
+});
diff --git a/src/remote/submissions.ts b/src/remote/submissions.ts
new file mode 100644
index 0000000..3f3b3a2
--- /dev/null
+++ b/src/remote/submissions.ts
@@ -0,0 +1,26 @@
+const KEY = "omni:remote-pending-submissions";
+type Pending = Record;
+
+/** Keep uncertain submissions across reloads; only an acknowledgement clears them.
+ * getRandomValues also works on plain-HTTP origins (randomUUID does not).
+ */
+export function submissionId(storage: Storage, scope: string[]): string {
+ const pending: Pending = JSON.parse(storage.getItem(KEY) ?? "{}");
+ const key = JSON.stringify(scope);
+ if (pending[key]) return pending[key];
+ const id = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) =>
+ b.toString(16).padStart(2, "0"),
+ ).join("");
+ pending[key] = id;
+ storage.setItem(KEY, JSON.stringify(pending));
+ return id;
+}
+
+export function acknowledgeSubmission(storage: Storage, scope: string[], id: string): void {
+ const pending: Pending = JSON.parse(storage.getItem(KEY) ?? "{}");
+ const key = JSON.stringify(scope);
+ if (pending[key] === id) {
+ delete pending[key];
+ storage.setItem(KEY, JSON.stringify(pending));
+ }
+}