Merge PR #4049: fix(collab): enable mobile ask responses (@roboomp)

# Conflicts:
#	packages/coding-agent/src/collab/protocol.ts
This commit is contained in:
can1357
2026-07-01 21:53:00 +02:00
13 changed files with 513 additions and 7 deletions
+3
View File
@@ -12,6 +12,9 @@
- Improved robustness of MCP authentication error detection and header-based server discovery
- Fixed `search` and AST tools accepting external read URLs by materializing fetched URL text through the read cache before path resolution. ([#3649](https://github.com/can1357/oh-my-pi/issues/3649))
- Fixed reliable detection of 401/403 authorization failures during Smithery commands and HTTP RPCs
### Fixed
- Fixed `/collab` web guests being unable to answer `ask` tool questions by routing host UI requests through writable collab peers. ([#4035](https://github.com/can1357/oh-my-pi/issues/4035))
## [16.2.12] - 2026-07-01
+61 -1
View File
@@ -13,7 +13,14 @@ import { timingSafeEqual } from "node:crypto";
import * as fs from "node:fs/promises";
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { logger } from "@oh-my-pi/pi-utils";
import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire";
import type {
BusChannel,
CollabUiRequest,
CollabUiRequestDraft,
CollabUiResponseValue,
AgentEvent as WireAgentEvent,
SessionEntry as WireSessionEntry,
} from "@oh-my-pi/pi-wire";
import type { InteractiveModeContext } from "../modes/types";
import { AgentLifecycleManager } from "../registry/agent-lifecycle";
import { type AgentRef, AgentRegistry } from "../registry/agent-registry";
@@ -115,6 +122,8 @@ export class CollabHost {
#sessionId = "";
#unsubscribe?: () => void;
#peers = new Map<number, { name: string; canWrite: boolean }>();
#uiReqSeq = 0;
#pendingUi = new Map<number, { resolve(value: CollabUiResponseValue): void }>();
#lastStateJson = "";
#stateDebounce: Timer | null = null;
#streamingInterval: Timer | null = null;
@@ -154,6 +163,43 @@ export class CollabHost {
return list;
}
requestGuestUi(request: CollabUiRequestDraft, signal?: AbortSignal): Promise<CollabUiResponseValue> | null {
if (!this.#socket || !this.#hasWritablePeers()) return null;
const reqId = ++this.#uiReqSeq;
const fullRequest: CollabUiRequest = { ...request, reqId };
const { promise, resolve } = Promise.withResolvers<CollabUiResponseValue>();
let settled = false;
const settle = (value: CollabUiResponseValue): void => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", onAbort);
this.#pendingUi.delete(reqId);
this.#sendWritablePeers({ t: "ui-request-end", reqId });
resolve(value);
};
const onAbort = (): void => settle(undefined);
if (signal?.aborted) return Promise.resolve(undefined);
signal?.addEventListener("abort", onAbort, { once: true });
this.#pendingUi.set(reqId, { resolve: settle });
this.#sendWritablePeers({ t: "ui-request", request: fullRequest });
return promise;
}
#hasWritablePeers(): boolean {
for (const peer of this.#peers.values()) {
if (peer.canWrite) return true;
}
return false;
}
#sendWritablePeers(frame: CollabFrame): void {
const socket = this.#socket;
if (!socket) return;
for (const [peerId, peer] of this.#peers) {
if (peer.canWrite) socket.send(frame, peerId);
}
}
async start(relayUrl: string, webUrl = ""): Promise<void> {
const rawKey = generateRoomKey();
const writeToken = generateWriteToken();
@@ -256,6 +302,8 @@ export class CollabHost {
this.#agentsDebounce = null;
clearInterval(this.#streamingInterval ?? undefined);
this.#streamingInterval = null;
for (const pending of this.#pendingUi.values()) pending.resolve(undefined);
this.#pendingUi.clear();
this.#peers.clear();
this.#socket?.close();
this.#socket = null;
@@ -288,6 +336,9 @@ export class CollabHost {
case "agent-cmd":
this.#handleAgentCmd(frame.cmd, frame.agentId, frame.text, fromPeer);
break;
case "ui-response":
this.#handleUiResponse(frame.reqId, frame.value, fromPeer);
break;
case "fetch-transcript":
void this.#handleFetchTranscript(frame.reqId, frame.agentId, frame.fromByte, fromPeer);
break;
@@ -393,6 +444,15 @@ export class CollabHost {
}
}
#handleUiResponse(reqId: number, value: CollabUiResponseValue, fromPeer: number): void {
const peer = this.#peers.get(fromPeer);
if (!peer?.canWrite) {
this.#rejectReadOnly("responding to ask", fromPeer);
return;
}
this.#pendingUi.get(reqId)?.resolve(value);
}
#handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
const peer = this.#peers.get(fromPeer);
if (!peer?.canWrite) {
+8 -1
View File
@@ -10,6 +10,7 @@
import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
import type {
BusChannel,
CollabUiRequest,
GuestFrame,
ParsedCollabLink,
Participant,
@@ -29,6 +30,10 @@ import type { SessionEntry, SessionHeader } from "../session/session-entries";
export type {
CollabPromptDetails,
CollabUiRequest,
CollabUiRequestDraft,
CollabUiResponseValue,
CollabUiSelectItem,
ParsedCollabLink,
RelayControlMessage,
RelayControlToGuest,
@@ -57,7 +62,7 @@ export type CollabSessionState = SessionState & {
* that serialize into those shapes.
*/
export type CollabFrame =
// guest -> host (hello/abort/agent-cmd/fetch-transcript are taken verbatim from the wire grammar)
// guest -> host (hello/abort/agent-cmd/fetch-transcript/ui-response are taken verbatim from the wire grammar)
| Exclude<GuestFrame, { t: "prompt" }>
| { t: "prompt"; text: string; images?: ImageContent[] }
// host -> guest
@@ -93,6 +98,8 @@ export type CollabFrame =
| { t: "bus"; channel: BusChannel; data: unknown }
/** Full agent-registry snapshot (debounced on registry change). */
| { t: "agents"; agents: AgentSnapshot[] }
| { t: "ui-request"; request: CollabUiRequest }
| { t: "ui-request-end"; reqId: number }
/** Targeted reply to fetch-transcript; `error` marks a terminal read failure that guests must surface without hot retrying. */
| { t: "transcript"; reqId: number; text: string; newSize: number; error?: string }
| { t: "bye"; reason: string }
@@ -1,5 +1,6 @@
import type { Component, OverlayHandle, TUI } from "@oh-my-pi/pi-tui";
import { Container, Spacer, Text } from "@oh-my-pi/pi-tui";
import type { CollabUiRequestDraft, CollabUiSelectItem } from "@oh-my-pi/pi-wire";
import { KeybindingsManager } from "../../config/keybindings";
import type {
CompactOptions,
@@ -28,6 +29,31 @@ import { setSessionTerminalTitle, setTerminalTitle } from "../../utils/title-gen
const MAX_WIDGET_LINES = 10;
interface CollabDialogWinner {
source: "local" | "remote";
value: string | undefined;
}
function toWireSelectOptions(options: ExtensionUISelectItem[]): CollabUiSelectItem[] {
return options.map(option =>
typeof option === "string"
? option
: option.description
? { label: option.label, description: option.description }
: { label: option.label },
);
}
function mergeAbortSignals(first: AbortSignal | undefined, second: AbortSignal): AbortSignal {
if (!first) return second;
if (first.aborted) return first;
const controller = new AbortController();
const abort = (): void => controller.abort();
first.addEventListener("abort", abort, { once: true });
second.addEventListener("abort", abort, { once: true });
return controller.signal;
}
export class ExtensionUiController {
#extensionTerminalInputUnsubscribers = new Set<() => void>();
#hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
@@ -45,7 +71,7 @@ export class ExtensionUiController {
async initHooksAndCustomTools(): Promise<void> {
// Create and set hook & tool UI context
const uiContext: ExtensionUIContext = {
select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions),
select: (title, options, dialogOptions) => this.showCollabAwareSelector(title, options, dialogOptions),
confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
notify: (message, type) => this.showHookNotify(message, type),
@@ -61,7 +87,7 @@ export class ExtensionUiController {
},
getEditorText: () => this.ctx.editor.getText(),
editor: (title, prefill, dialogOptions, editorOptions) =>
this.showHookEditor(title, prefill, dialogOptions, editorOptions),
this.showCollabAwareEditor(title, prefill, dialogOptions, editorOptions),
get theme() {
return theme;
},
@@ -531,6 +557,59 @@ export class ExtensionUiController {
this.ctx.ui.requestRender();
}
async showCollabAwareSelector(
title: string,
options: ExtensionUISelectItem[],
dialogOptions?: InteractiveSelectorDialogOptions,
extra?: { slider?: HookSelectorSlider },
): Promise<string | undefined> {
const request: CollabUiRequestDraft = {
kind: "select",
title,
options: toWireSelectOptions(options),
initialIndex: dialogOptions?.initialIndex,
selectionMarker: dialogOptions?.selectionMarker,
checkedIndices: dialogOptions?.checkedIndices ? [...dialogOptions.checkedIndices] : undefined,
markableCount: dialogOptions?.markableCount,
helpText: dialogOptions?.helpText,
};
return this.#raceCollabDialog(request, dialogOptions?.signal, signal =>
this.showHookSelector(title, options, { ...dialogOptions, signal }, extra),
);
}
async showCollabAwareEditor(
title: string,
prefill?: string,
dialogOptions?: ExtensionUIDialogOptions,
editorOptions?: { promptStyle?: boolean },
): Promise<string | undefined> {
const request: CollabUiRequestDraft = { kind: "editor", title, prefill };
return this.#raceCollabDialog(request, dialogOptions?.signal, signal =>
this.showHookEditor(title, prefill, { ...dialogOptions, signal }, editorOptions),
);
}
async #raceCollabDialog(
request: CollabUiRequestDraft,
signal: AbortSignal | undefined,
local: (signal: AbortSignal) => Promise<string | undefined>,
): Promise<string | undefined> {
const host = this.ctx.collabHost;
if (!host) return local(signal ?? new AbortController().signal);
const localAbort = new AbortController();
const remoteAbort = new AbortController();
const remote = host.requestGuestUi(request, mergeAbortSignals(signal, remoteAbort.signal));
if (!remote) return local(signal ?? new AbortController().signal);
const localSignal = mergeAbortSignals(signal, localAbort.signal);
const localWinner = local(localSignal).then((value): CollabDialogWinner => ({ source: "local", value }));
const remoteWinner = remote.then((value): CollabDialogWinner => ({ source: "remote", value }));
const winner = await Promise.race([localWinner, remoteWinner]);
if (winner.source === "remote") localAbort.abort();
else remoteAbort.abort();
return winner.value;
}
/**
* Show a selector for hooks.
*/
@@ -323,6 +323,24 @@ describe("collab read-only links", () => {
expect(host.participants.find(p => p.name === "writer")?.readOnly).toBeUndefined();
});
it("routes host UI requests to write guests and resolves their response", async () => {
const guest = await joinAsGuest(host.link, "writer-ui");
guestCleanups.push(() => guest.socket.close());
const welcome = await guest.nextFrame();
if (welcome.t !== "welcome") throw new Error(`expected welcome, got ${welcome.t}`);
const pending = host.requestGuestUi({ kind: "select", title: "Continue?", options: ["Yes"] });
if (!pending) throw new Error("expected writable guest UI request");
const request = await guest.nextFrame();
if (request.t !== "ui-request") throw new Error(`expected ui-request, got ${request.t}`);
expect(request.request).toMatchObject({ kind: "select", title: "Continue?", options: ["Yes"] });
guest.socket.send({ t: "ui-response", reqId: request.request.reqId, value: "Yes" });
expect(await pending).toBe("Yes");
const end = await guest.nextFrame();
expect(end).toEqual({ t: "ui-request-end", reqId: request.request.reqId });
});
it("treats a forged write token as read-only", async () => {
const { prompts } = harness;
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed the mobile collab web UI showing `ask` questions without response controls. ([#4035](https://github.com/can1357/oh-my-pi/issues/4035))
## [16.2.0] - 2026-06-27
### Added
@@ -1,6 +1,6 @@
import { SendHorizontal, Square } from "lucide-react";
import type { KeyboardEvent, ReactNode } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { GuestClient, GuestSnapshot } from "../../lib/client";
export interface ComposerProps {
@@ -13,16 +13,30 @@ const LINE_PX = 20;
const PAD_Y = 16;
const MAX_ROWS = 8;
function optionLabel(option: string | { label: string }): string {
return typeof option === "string" ? option : option.label;
}
export function Composer({ client, snapshot }: ComposerProps): ReactNode {
const [text, setText] = useState("");
const [uiDraft, setUiDraft] = useState(
snapshot.uiRequest?.kind === "editor" ? (snapshot.uiRequest.prefill ?? "") : "",
);
const taRef = useRef<HTMLTextAreaElement | null>(null);
const live = snapshot.phase === "live";
const readOnly = snapshot.readOnly;
const uiRequest = snapshot.uiRequest;
const uiRequestPrefill = uiRequest?.kind === "editor" ? uiRequest.prefill : undefined;
const canPrompt = live && !readOnly;
const busy = snapshot.working || (snapshot.state?.isStreaming ?? false);
const queued = snapshot.state?.queuedMessageCount ?? 0;
const canSend = canPrompt && text.trim().length > 0;
const canSubmitUiDraft = canPrompt && uiRequest?.kind === "editor";
useEffect(() => {
setUiDraft(uiRequest?.kind === "editor" ? (uiRequestPrefill ?? "") : "");
}, [uiRequest?.reqId, uiRequest?.kind, uiRequestPrefill]);
useLayoutEffect(() => {
const el = taRef.current;
@@ -31,7 +45,7 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
const max = MAX_ROWS * LINE_PX + PAD_Y;
el.style.height = `${Math.max(LINE_PX + PAD_Y, Math.min(el.scrollHeight, max))}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text]);
}, [text, uiDraft, uiRequest?.reqId]);
const send = useCallback((): void => {
const trimmed = text.trim();
@@ -40,6 +54,12 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
setText("");
}, [client, live, readOnly, text]);
const submitUiDraft = useCallback((): void => {
if (!canPrompt || uiRequest?.kind !== "editor") return;
client.sendUiResponse(uiRequest.reqId, uiDraft);
setUiDraft("");
}, [canPrompt, client, uiDraft, uiRequest]);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -47,6 +67,87 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode {
}
};
const onUiKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submitUiDraft();
}
};
if (uiRequest && canPrompt) {
return (
<div className="sh-composer sh-composer-ask">
<div className="sh-ask-title">{uiRequest.title}</div>
{uiRequest.kind === "select" ? (
<div className="sh-ask-options">
{uiRequest.options.map((option, index) => {
const label = optionLabel(option);
const checked = uiRequest.checkedIndices?.includes(index) ?? false;
return (
<button
key={`${uiRequest.reqId}-${index}-${label}`}
type="button"
className={`sh-ask-option${checked ? " sh-ask-option-checked" : ""}`}
onClick={() => client.sendUiResponse(uiRequest.reqId, label)}
>
<span className="sh-ask-option-marker">
{uiRequest.selectionMarker === "checkbox" ? (checked ? "☑" : "☐") : checked ? "◉" : "○"}
</span>
<span className="sh-ask-option-copy">
<span className="sh-ask-option-label">{label}</span>
{typeof option !== "string" && option.description && (
<span className="sh-ask-option-description">{option.description}</span>
)}
</span>
</button>
);
})}
</div>
) : (
<div className="sh-composer-inner">
<textarea
ref={taRef}
className="sh-composer-input"
value={uiDraft}
onChange={e => setUiDraft(e.target.value)}
onKeyDown={onUiKeyDown}
placeholder="type your response…"
rows={1}
spellCheck={false}
/>
<div className="sh-composer-actions">
<button
type="button"
className="sh-btn sh-btn-primary"
onClick={submitUiDraft}
disabled={!canSubmitUiDraft}
title="submit response"
>
<SendHorizontal size={12} /> <span className="sh-btn-label">Submit</span>
</button>
</div>
</div>
)}
<div className="sh-composer-actions sh-ask-actions">
<button type="button" className="sh-btn" onClick={() => client.sendUiResponse(uiRequest.reqId)}>
Cancel
</button>
{busy && (
<button
type="button"
className="sh-btn sh-btn-stop"
onClick={() => client.sendAbort()}
disabled={!live}
title="stop the current turn"
>
<Square size={11} /> <span className="sh-btn-label">Stop</span>
</button>
)}
</div>
</div>
);
}
return (
<div className="sh-composer">
<div className="sh-composer-inner">
@@ -348,6 +348,79 @@
padding-bottom: 1px;
}
.sh-composer-ask {
display: grid;
gap: 8px;
}
.sh-ask-title {
max-width: 880px;
margin: 0 auto;
font-size: 13px;
font-weight: 600;
color: var(--fg);
white-space: pre-wrap;
}
.sh-ask-options {
display: grid;
gap: 6px;
max-width: 880px;
margin: 0 auto;
}
.sh-ask-option {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
padding: 8px 10px;
color: var(--fg);
text-align: left;
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.sh-ask-option:hover {
border-color: var(--ring);
}
.sh-ask-option-checked {
border-color: var(--accent);
background: var(--accent-muted);
}
.sh-ask-option-marker {
width: 18px;
font: 400 13px / 18px var(--font-mono);
color: var(--accent);
}
.sh-ask-option-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.sh-ask-option-label {
font-size: 13px;
font-weight: 500;
}
.sh-ask-option-description {
font-size: 12px;
line-height: 1.35;
color: var(--fg-muted);
}
.sh-ask-actions {
justify-content: flex-end;
max-width: 880px;
width: 100%;
margin: 0 auto;
}
.sh-queued {
padding: 1px 6px;
font: 400 11px / 1.5 var(--font-mono);
@@ -654,7 +727,9 @@
}
.sh-composer-input,
.sh-input,
.sh-input-mono {
.sh-input-mono,
.sh-ask-title,
.sh-ask-option-label {
font-size: 16px;
}
}
+22
View File
@@ -11,6 +11,8 @@
import type {
AgentSnapshot,
AssistantMessage,
CollabUiRequest,
CollabUiResponseValue,
HostFrame,
SessionEntry,
SessionHeader,
@@ -59,6 +61,8 @@ export interface GuestSnapshot {
working: boolean;
/** True when this guest joined through a read-only (view) link. */
readOnly: boolean;
/** Pending host-side UI request (`ask` select/editor) this guest can answer. */
uiRequest: CollabUiRequest | null;
/** Capped at 50, newest last. */
notices: readonly Notice[];
}
@@ -102,6 +106,7 @@ export class GuestClient {
#activeTools: ReadonlyMap<string, ActiveTool> = new Map();
#working = false;
#readOnly = false;
#uiRequest: CollabUiRequest | null = null;
#notices: readonly Notice[] = [];
#snapshot: GuestSnapshot;
@@ -158,6 +163,14 @@ export class GuestClient {
this.#socket.send({ t: "prompt", text });
}
sendUiResponse(reqId: number, value?: CollabUiResponseValue): void {
this.#socket.send({ t: "ui-response", reqId, value });
if (this.#uiRequest?.reqId === reqId) {
this.#uiRequest = null;
this.#commit();
}
}
sendAbort(): void {
this.#socket.send({ t: "abort" });
}
@@ -213,6 +226,7 @@ export class GuestClient {
pending.resolve(null);
}
this.#pendingTranscripts.clear();
this.#uiRequest = null;
this.#commit();
this.#socket.close();
}
@@ -270,6 +284,7 @@ export class GuestClient {
this.#lifecycle = new Map();
this.#working = frame.state.isStreaming;
this.#readOnly = frame.readOnly === true;
this.#uiRequest = null;
this.#welcomed = true;
this.#clearWelcomeTimer();
if (frame.entryCount === 0) {
@@ -325,6 +340,12 @@ export class GuestClient {
this.#lifecycle = new Map(this.#lifecycle).set(payload.id, payload);
}
break;
case "ui-request":
this.#uiRequest = frame.request;
break;
case "ui-request-end":
if (this.#uiRequest?.reqId === frame.reqId) this.#uiRequest = null;
break;
case "transcript": {
const pending = this.#pendingTranscripts.get(frame.reqId);
if (pending) {
@@ -448,6 +469,7 @@ export class GuestClient {
activeTools: this.#activeTools,
working: this.#working,
readOnly: this.#readOnly,
uiRequest: this.#uiRequest,
notices: this.#notices,
};
}
+38
View File
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "bun:test";
import type {
AgentSnapshot,
AssistantMessage,
GuestFrame,
HostFrame,
SessionEntry,
SessionHeader,
@@ -11,6 +12,7 @@ import type {
} from "@oh-my-pi/pi-wire";
import { GuestClient } from "../src/lib/client";
import { encodeBase64Url } from "../src/lib/link";
import { CollabSocket } from "../src/lib/socket";
const LINK = `roomroomroom1234#${encodeBase64Url(new Uint8Array(32))}`;
@@ -232,6 +234,42 @@ describe("GuestClient frame apply", () => {
expect(notices[0]).toMatchObject({ level: "error", message: "boom" });
});
it("tracks host UI requests and sends responses", () => {
const sent: GuestFrame[] = [];
const sendSpy = vi.spyOn(CollabSocket.prototype, "send").mockImplementation((frame: GuestFrame) => {
sent.push(frame);
});
try {
const client = liveClient();
const request = {
reqId: 7,
kind: "select" as const,
title: "Continue?",
options: ["Yes", { label: "No", description: "Stop here" }],
selectionMarker: "radio" as const,
};
client.applyFrameForTest({ t: "ui-request", request });
expect(client.getSnapshot().uiRequest).toEqual(request);
client.sendUiResponse(7, "Yes");
expect(sent).toEqual([{ t: "ui-response", reqId: 7, value: "Yes" }]);
expect(client.getSnapshot().uiRequest).toBeNull();
} finally {
sendSpy.mockRestore();
}
});
it("clears pending host UI requests when the host ends them", () => {
const client = liveClient();
client.applyFrameForTest({
t: "ui-request",
request: { reqId: 8, kind: "editor", title: "Other", prefill: "draft" },
});
expect(client.getSnapshot().uiRequest?.reqId).toBe(8);
client.applyFrameForTest({ t: "ui-request-end", reqId: 8 });
expect(client.getSnapshot().uiRequest).toBeNull();
});
it("snapshot reference is stable between frames and replaced per frame", () => {
const client = liveClient();
const before = client.getSnapshot();
@@ -0,0 +1,69 @@
import { describe, expect, it } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { GuestSnapshot } from "../src/lib/client";
import { GuestClient } from "../src/lib/client";
import { Composer } from "../src/components/shell/Composer";
import { encodeBase64Url } from "../src/lib/link";
const LINK = `roomroomroom1234#${encodeBase64Url(new Uint8Array(32))}`;
const client = new GuestClient(LINK, "tester");
function snapshot(uiRequest: GuestSnapshot["uiRequest"]): GuestSnapshot {
return {
phase: "live",
endedReason: null,
header: null,
entries: [],
state: { isStreaming: true, queuedMessageCount: 0, cwd: "/work", participants: [] },
agents: [],
progress: new Map(),
lifecycle: new Map(),
stream: null,
streamDone: false,
activeTools: new Map(),
working: true,
readOnly: false,
uiRequest,
notices: [],
};
}
describe("Composer host UI requests", () => {
it("renders selectable ask responses for mobile guests", () => {
const html = renderToStaticMarkup(
<Composer
client={client}
snapshot={snapshot({
reqId: 1,
kind: "select",
title: "Continue?",
options: ["Yes", { label: "No", description: "Stop here" }],
selectionMarker: "radio",
})}
/>,
);
expect(html).toContain("Continue?");
expect(html).toContain("Yes");
expect(html).toContain("Stop here");
});
it("renders a submit field for custom ask responses", () => {
const html = renderToStaticMarkup(
<Composer client={client} snapshot={snapshot({ reqId: 2, kind: "editor", title: "Other", prefill: "draft" })} />,
);
expect(html).toContain("Other");
expect(html).toContain("draft");
expect(html).toContain("Submit");
});
it("keeps the editor submit enabled for whitespace-only drafts", () => {
const html = renderToStaticMarkup(
<Composer client={client} snapshot={snapshot({ reqId: 3, kind: "editor", title: "Other", prefill: " " })} />,
);
expect(html).toContain('title="submit response"');
expect(html).not.toMatch(/title="submit response"[^>]*disabled/);
});
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added collab UI request/response wire frames so browser guests can answer host-side interactive prompts. ([#4035](https://github.com/can1357/oh-my-pi/issues/4035))
## [16.1.8] - 2026-06-20
### Breaking Changes
+26
View File
@@ -298,6 +298,29 @@ export interface SubagentLifecyclePayload {
// Frames (JSON inside the AES-GCM seal)
// ═══════════════════════════════════════════════════════════════════════════
export type CollabUiSelectItem = string | { label: string; description?: string };
export type CollabUiResponseValue = string | undefined;
export type CollabUiRequestDraft =
| {
kind: "select";
title: string;
options: CollabUiSelectItem[];
initialIndex?: number;
selectionMarker?: "radio" | "checkbox";
checkedIndices?: number[];
markableCount?: number;
helpText?: string;
}
| {
kind: "editor";
title: string;
prefill?: string;
};
export type CollabUiRequest = CollabUiRequestDraft & { reqId: number };
export type GuestFrame =
| {
t: "hello";
@@ -311,6 +334,7 @@ export type GuestFrame =
writeToken?: string;
}
| { t: "prompt"; text: string; images?: ImageContent[] }
| { t: "ui-response"; reqId: number; value?: CollabUiResponseValue }
| { t: "abort" }
| { t: "agent-cmd"; cmd: "chat" | "kill" | "revive"; agentId: string; text?: string }
| { t: "fetch-transcript"; reqId: number; agentId: string; fromByte: number };
@@ -348,6 +372,8 @@ export type HostFrame =
/** Mirrored EventBus traffic (task subagent lifecycle/progress channels only). */
| { t: "bus"; channel: BusChannel; data: unknown }
| { t: "agents"; agents: AgentSnapshot[] }
| { t: "ui-request"; request: CollabUiRequest }
| { t: "ui-request-end"; reqId: number }
/** Targeted reply to fetch-transcript; `text` is decoded JSONL from `fromByte`, `newSize` the next offset base. */
| { t: "transcript"; reqId: number; text: string; newSize: number; error?: string }
| { t: "bye"; reason: string }