Merge PR #4049: fix(collab): enable mobile ask responses (@roboomp)
# Conflicts: # packages/coding-agent/src/collab/protocol.ts
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user