From 9d3918bdac0105b1421813eec8c66ae92ae5f503 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 1 Jul 2026 05:15:37 +0000 Subject: [PATCH 1/2] fix(collab): enabled mobile ask responses Routed host UI requests through writable collab web guests and added mobile response controls for ask questions. Fixes #4035 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/collab/host.ts | 62 +++++++++- packages/coding-agent/src/collab/protocol.ts | 9 +- .../controllers/extension-ui-controller.ts | 83 +++++++++++++- .../test/collab/read-only.test.ts | 18 +++ packages/collab-web/CHANGELOG.md | 4 + .../src/components/shell/Composer.tsx | 106 +++++++++++++++++- .../collab-web/src/components/shell/shell.css | 77 ++++++++++++- packages/collab-web/src/lib/client.ts | 22 ++++ packages/collab-web/test/client.test.ts | 38 +++++++ packages/collab-web/test/composer.test.tsx | 60 ++++++++++ packages/wire/CHANGELOG.md | 4 + packages/wire/src/index.ts | 26 +++++ 13 files changed, 506 insertions(+), 7 deletions(-) create mode 100644 packages/collab-web/test/composer.test.tsx diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d1722f2ec..7a15ac5d1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 ### Breaking Changes diff --git a/packages/coding-agent/src/collab/host.ts b/packages/coding-agent/src/collab/host.ts index daf258557..65c5584fb 100644 --- a/packages/coding-agent/src/collab/host.ts +++ b/packages/coding-agent/src/collab/host.ts @@ -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"; @@ -114,6 +121,8 @@ export class CollabHost { #sessionId = ""; #unsubscribe?: () => void; #peers = new Map(); + #uiReqSeq = 0; + #pendingUi = new Map(); #lastStateJson = ""; #stateDebounce: Timer | null = null; #streamingInterval: Timer | null = null; @@ -153,6 +162,43 @@ export class CollabHost { return list; } + requestGuestUi(request: CollabUiRequestDraft, signal?: AbortSignal): Promise | null { + if (!this.#socket || !this.#hasWritablePeers()) return null; + const reqId = ++this.#uiReqSeq; + const fullRequest: CollabUiRequest = { ...request, reqId }; + const { promise, resolve } = Promise.withResolvers(); + 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 { const rawKey = generateRoomKey(); const writeToken = generateWriteToken(); @@ -255,6 +301,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; @@ -287,6 +335,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; @@ -392,6 +443,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) { diff --git a/packages/coding-agent/src/collab/protocol.ts b/packages/coding-agent/src/collab/protocol.ts index bc5cc514f..f98af4ad0 100644 --- a/packages/coding-agent/src/collab/protocol.ts +++ b/packages/coding-agent/src/collab/protocol.ts @@ -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 | { 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; `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 } diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index a380b1d3e..58573c0d9 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -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(); @@ -45,7 +71,7 @@ export class ExtensionUiController { async initHooksAndCustomTools(): Promise { // 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 { + 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 { + 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, + ): Promise { + 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. */ diff --git a/packages/coding-agent/test/collab/read-only.test.ts b/packages/coding-agent/test/collab/read-only.test.ts index 91c893151..60e54d9f0 100644 --- a/packages/coding-agent/test/collab/read-only.test.ts +++ b/packages/coding-agent/test/collab/read-only.test.ts @@ -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; diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index 511b8ab72..7f319ccee 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -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 diff --git a/packages/collab-web/src/components/shell/Composer.tsx b/packages/collab-web/src/components/shell/Composer.tsx index 8a0e183c3..119fd9e19 100644 --- a/packages/collab-web/src/components/shell/Composer.tsx +++ b/packages/collab-web/src/components/shell/Composer.tsx @@ -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(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" && uiDraft.trim().length > 0; + + 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,13 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode { setText(""); }, [client, live, readOnly, text]); + const submitUiDraft = useCallback((): void => { + const trimmed = uiDraft.trim(); + if (!trimmed || !canPrompt || uiRequest?.kind !== "editor") return; + client.sendUiResponse(uiRequest.reqId, trimmed); + setUiDraft(""); + }, [canPrompt, client, uiDraft, uiRequest]); + const onKeyDown = (e: KeyboardEvent): void => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -47,6 +68,87 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode { } }; + const onUiKeyDown = (e: KeyboardEvent): void => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + submitUiDraft(); + } + }; + + if (uiRequest && canPrompt) { + return ( +
+
{uiRequest.title}
+ {uiRequest.kind === "select" ? ( +
+ {uiRequest.options.map((option, index) => { + const label = optionLabel(option); + const checked = uiRequest.checkedIndices?.includes(index) ?? false; + return ( + + ); + })} +
+ ) : ( +
+