From a21e6293c0bc9ab26c9fc55a3aa464795d02cf43 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:07:46 +0000 Subject: [PATCH 1/5] feat: add desktop channel question cards Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Oscar Le --- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/user_input.rs | 28 +++ desktop/src-tauri/src/lib.rs | 1 + .../channels/hooks/useChannelUserInput.ts | 145 ++++++++++++ .../features/channels/lib/userInput.test.mjs | 99 ++++++++ .../src/features/channels/lib/userInput.ts | 124 ++++++++++ .../src/features/channels/ui/ChannelPane.tsx | 13 ++ .../channels/ui/ChannelUserInputCard.tsx | 221 ++++++++++++++++++ .../channels/ui/ChannelUserInputStack.tsx | 47 ++++ desktop/src/shared/api/relayChannelFilters.ts | 16 ++ desktop/src/shared/api/tauriUserInput.ts | 13 ++ desktop/src/shared/constants/kinds.ts | 2 + desktop/src/testing/e2eBridge.ts | 34 +++ desktop/tests/e2e/channels.spec.ts | 82 +++++++ 14 files changed, 827 insertions(+) create mode 100644 desktop/src-tauri/src/commands/user_input.rs create mode 100644 desktop/src/features/channels/hooks/useChannelUserInput.ts create mode 100644 desktop/src/features/channels/lib/userInput.test.mjs create mode 100644 desktop/src/features/channels/lib/userInput.ts create mode 100644 desktop/src/features/channels/ui/ChannelUserInputCard.tsx create mode 100644 desktop/src/features/channels/ui/ChannelUserInputStack.tsx create mode 100644 desktop/src/shared/api/tauriUserInput.ts diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index d9c766b26c5..cb7296aadff 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -61,6 +61,7 @@ mod thread_workspace_git; #[cfg(test)] mod thread_workspace_tests; mod updater; +mod user_input; mod window_chrome; mod window_vibrancy; mod workflows; @@ -114,6 +115,7 @@ pub use teams::*; pub use thread_github::*; pub use thread_workspace::*; pub use updater::*; +pub use user_input::*; pub use window_chrome::*; pub use window_vibrancy::*; pub use workflows::*; diff --git a/desktop/src-tauri/src/commands/user_input.rs b/desktop/src-tauri/src/commands/user_input.rs new file mode 100644 index 00000000000..1a633007569 --- /dev/null +++ b/desktop/src-tauri/src/commands/user_input.rs @@ -0,0 +1,28 @@ +use tauri::State; + +use crate::{ + app_state::AppState, + relay::{submit_event, SubmitEventResponse}, +}; + +/// Publish an owner-authored answer to a durable agent question. +#[tauri::command] +pub async fn send_channel_user_input_answer( + channel_id: String, + request_event_id: String, + answers: serde_json::Value, + state: State<'_, AppState>, +) -> Result { + let channel_uuid = uuid::Uuid::parse_str(&channel_id) + .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; + if !answers.is_object() { + return Err("answers must be a JSON object".to_string()); + } + let builder = buzz_sdk_pkg::build_agent_user_input_answer( + channel_uuid, + &request_event_id, + &answers.to_string(), + ) + .map_err(|error| error.to_string())?; + submit_event(builder, &state).await +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6ad9f30e175..5f456b55d3b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -724,6 +724,7 @@ pub fn run() { get_feed, search_messages, send_channel_message, + send_channel_user_input_answer, send_managed_agent_channel_message, has_managed_agent_channel_message_marker, get_forum_posts, diff --git a/desktop/src/features/channels/hooks/useChannelUserInput.ts b/desktop/src/features/channels/hooks/useChannelUserInput.ts new file mode 100644 index 00000000000..7457c654a8f --- /dev/null +++ b/desktop/src/features/channels/hooks/useChannelUserInput.ts @@ -0,0 +1,145 @@ +import * as React from "react"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { relayClient } from "@/shared/api/relayClient"; +import { buildChannelUserInputFilter } from "@/shared/api/relayChannelFilters"; +import type { RelayEvent } from "@/shared/api/types"; +import { sendChannelUserInputAnswer } from "@/shared/api/tauriUserInput"; +import { + buildSkippedAnswers, + buildUserInputAnswers, + derivePendingUserInputs, + type UserInputAnswers, + type UserInputEvent, +} from "@/features/channels/lib/userInput"; + +const RETAINED_EVENTS = 200; + +export function useChannelUserInput(channelId: string | null) { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey ?? ""; + const [events, setEvents] = React.useState([]); + const [optimisticallyResolved, setOptimisticallyResolved] = React.useState( + () => new Set(), + ); + const [sendingRequestId, setSendingRequestId] = React.useState( + null, + ); + const [sentRequestIds, setSentRequestIds] = React.useState( + () => new Set(), + ); + + React.useEffect(() => { + setEvents([]); + setOptimisticallyResolved(new Set()); + setSentRequestIds(new Set()); + if (!channelId) return; + + let cancelled = false; + const filter = buildChannelUserInputFilter(channelId, RETAINED_EVENTS); + const onEvent = (event: RelayEvent) => { + if (cancelled) return; + setEvents((current) => { + if (current.some((existing) => existing.id === event.id)) + return current; + return [event, ...current].slice(0, RETAINED_EVENTS); + }); + }; + + const load = async () => { + try { + const dispose = await relayClient.subscribeLive(filter, onEvent); + if (cancelled) { + await dispose(); + return; + } + const history = await relayClient.fetchEvents(filter); + if (!cancelled) { + setEvents((current) => { + const byId = new Map(current.map((event) => [event.id, event])); + for (const event of history) byId.set(event.id, event); + return [...byId.values()] + .sort((left, right) => right.created_at - left.created_at) + .slice(0, RETAINED_EVENTS); + }); + } + return dispose; + } catch (error) { + console.error("Failed to load agent questions", error); + } + }; + + let dispose: (() => Promise) | undefined; + void load().then((cleanup) => { + dispose = cleanup; + if (cancelled) void cleanup?.(); + }); + return () => { + cancelled = true; + void dispose?.(); + }; + }, [channelId]); + + const pending = React.useMemo( + () => + derivePendingUserInputs(events, currentPubkey, optimisticallyResolved), + [currentPubkey, events, optimisticallyResolved], + ); + const sent = React.useMemo(() => { + const active = new Set( + derivePendingUserInputs(events, currentPubkey).map( + ({ event }) => event.id, + ), + ); + return derivePendingUserInputs(events, currentPubkey, new Set()) + .filter( + ({ event }) => sentRequestIds.has(event.id) && active.has(event.id), + ) + .map((request) => request); + }, [currentPubkey, events, sentRequestIds]); + + const answer = React.useCallback( + async (request: UserInputEvent, answers: UserInputAnswers) => { + setSendingRequestId(request.event.id); + try { + await sendChannelUserInputAnswer( + channelId ?? request.request.channel_id, + request.event.id, + buildUserInputAnswers(answers), + ); + setOptimisticallyResolved((current) => { + const next = new Set(current); + next.add(request.event.id); + return next; + }); + setSentRequestIds((current) => { + const next = new Set(current); + next.add(request.event.id); + return next; + }); + } finally { + setSendingRequestId(null); + } + }, + [channelId], + ); + + const skip = React.useCallback( + (request: UserInputEvent) => + answer( + request, + buildSkippedAnswers(request.request.questions.map((q) => q.id)), + ), + [answer], + ); + + return { + pending, + sent, + currentPubkey, + sendingRequestId, + sentRequestIds, + answer, + skip, + isLoading: Boolean(channelId) && identityQuery.isLoading, + }; +} diff --git a/desktop/src/features/channels/lib/userInput.test.mjs b/desktop/src/features/channels/lib/userInput.test.mjs new file mode 100644 index 00000000000..3044d92f24d --- /dev/null +++ b/desktop/src/features/channels/lib/userInput.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + buildSkippedAnswers, + buildUserInputAnswers, + derivePendingUserInputs, +} from "./userInput.ts"; + +const request = (id, created_at = 10) => ({ + id, + pubkey: "agent", + created_at, + kind: 46040, + tags: [], + content: JSON.stringify({ + request_id: id, + session_id: "session", + turn_id: "turn", + channel_id: "channel", + engine: "claude", + message: "Choose", + questions: [ + { + id: "q0", + header: "Environment", + question: "Where?", + options: [], + }, + ], + }), + sig: "", +}); + +const answer = (id, pubkey = "owner") => ({ + id: `answer-${id}-${pubkey}`, + pubkey, + created_at: 20, + kind: 46041, + tags: [ + ["h", "channel"], + ["e", id], + ], + content: JSON.stringify({ q0: "production" }), + sig: "", +}); + +test("accepted answer resolves request", () => { + assert.equal( + derivePendingUserInputs( + [request("request-1"), answer("request-1")], + "owner", + ).length, + 0, + ); +}); + +test("foreign-author answer does not resolve request", () => { + assert.equal( + derivePendingUserInputs( + [request("request-1"), answer("request-1", "stranger")], + "owner", + ).length, + 1, + ); +}); + +test("duplicate events are deduplicated", () => { + assert.equal( + derivePendingUserInputs( + [request("request-1"), request("request-1")], + "owner", + ).length, + 1, + ); +}); + +test("optimistic resolution hides the card", () => { + assert.equal( + derivePendingUserInputs( + [request("request-1")], + "owner", + new Set(["request-1"]), + ).length, + 0, + ); +}); + +test("answer payloads preserve all wire shapes", () => { + assert.equal( + buildUserInputAnswers({ + q0: "production", + q1: ["lint", "tests"], + q2: { selected: "yes", choice_notes: { yes: "because" } }, + q3: null, + }), + '{"q0":"production","q1":["lint","tests"],"q2":{"selected":"yes","choice_notes":{"yes":"because"}},"q3":null}', + ); + assert.deepEqual(buildSkippedAnswers(["q0", "q1"]), { q0: null, q1: null }); +}); diff --git a/desktop/src/features/channels/lib/userInput.ts b/desktop/src/features/channels/lib/userInput.ts new file mode 100644 index 00000000000..8f0b8e05d5c --- /dev/null +++ b/desktop/src/features/channels/lib/userInput.ts @@ -0,0 +1,124 @@ +import { + KIND_AGENT_USER_INPUT_ANSWER, + KIND_AGENT_USER_INPUT_REQUESTED, +} from "@/shared/constants/kinds"; +import type { RelayEvent } from "@/shared/api/types"; + +export type UserInputOption = { + value: string; + label: string; + description: string; +}; + +export type UserInputQuestion = { + id: string; + header: string; + question: string; + options: UserInputOption[]; + multi_select?: boolean; + allow_custom_answer?: boolean; + allow_notes?: boolean; +}; + +export type UserInputRequest = { + request_id: string; + session_id: string; + turn_id: string; + channel_id: string; + tool_call_id?: string | null; + engine: "claude" | "codex" | string; + message?: string | null; + questions: UserInputQuestion[]; +}; + +export type UserInputEvent = { + event: RelayEvent; + request: UserInputRequest; +}; + +export type UserInputAnswerValue = + | string + | string[] + | { selected: string | string[]; choice_notes?: Record } + | null; + +export type UserInputAnswers = Record; + +export function parseUserInputRequest( + event: RelayEvent, +): UserInputRequest | null { + if (event.kind !== KIND_AGENT_USER_INPUT_REQUESTED) return null; + try { + const value = JSON.parse(event.content) as UserInputRequest; + if ( + !value || + typeof value !== "object" || + typeof value.request_id !== "string" || + !Array.isArray(value.questions) + ) { + return null; + } + return value; + } catch { + return null; + } +} + +export function getAnswerRequestId(event: RelayEvent): string | null { + if (event.kind !== KIND_AGENT_USER_INPUT_ANSWER) return null; + const tag = event.tags.find(([name]) => name === "e"); + return tag?.[1] ?? null; +} + +export function dedupeUserInputEvents( + events: RelayEvent[], + limit = 200, +): RelayEvent[] { + const byId = new Map(); + for (const event of events) { + if (!byId.has(event.id)) byId.set(event.id, event); + } + return [...byId.values()] + .sort((left, right) => right.created_at - left.created_at) + .slice(0, limit); +} + +export function derivePendingUserInputs( + events: RelayEvent[], + currentPubkey: string, + optimisticallyResolvedIds: ReadonlySet = new Set(), +): UserInputEvent[] { + const deduped = dedupeUserInputEvents(events); + const answered = new Set( + deduped + .filter( + (event) => + event.kind === KIND_AGENT_USER_INPUT_ANSWER && + event.pubkey === currentPubkey, + ) + .map(getAnswerRequestId) + .filter((id): id is string => id !== null), + ); + return deduped + .filter((event) => event.kind === KIND_AGENT_USER_INPUT_REQUESTED) + .map((event) => { + const request = parseUserInputRequest(event); + return request ? { event, request } : null; + }) + .filter((item): item is UserInputEvent => item !== null) + .filter( + ({ event }) => + !answered.has(event.id) && !optimisticallyResolvedIds.has(event.id), + ) + .sort((left, right) => right.event.created_at - left.event.created_at); +} + +export function buildUserInputAnswers( + values: Record, +): string { + return JSON.stringify(values); +} + +export function buildSkippedAnswers(questionIds: string[]): UserInputAnswers { + return Object.fromEntries(questionIds.map((id) => [id, null])); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 13f0acd0c0a..ed78fc9fe71 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -38,6 +38,8 @@ import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPre import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory"; +import { ChannelUserInputStack } from "@/features/channels/ui/ChannelUserInputStack"; +import { useChannelUserInput } from "@/features/channels/hooks/useChannelUserInput"; import { useThreadComposerBotActivity } from "@/features/channels/ui/useThreadComposerBotActivity"; import { containsWelcomePersonaMention, @@ -182,6 +184,7 @@ export const ChannelPane = React.memo(function ChannelPane({ !activeChannel.archivedAt; const hasMainComposerOverlay = !isNonMemberView; const activeChannelId = activeChannel?.id ?? null; + const userInput = useChannelUserInput(activeChannelId); const activeChannelIdRef = React.useRef(activeChannelId); const channelPaneMountedRef = React.useRef(false); activeChannelIdRef.current = activeChannelId; @@ -740,6 +743,16 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} + {userInput.pending.length > 0 || userInput.sent.length > 0 ? ( + + ) : null} ; + sent?: boolean; + onSubmit: ( + item: UserInputEvent, + answers: Record, + ) => Promise; + onSkip: (item: UserInputEvent) => Promise; +}; + +type QuestionState = { + selected: string[]; + custom: string; + notes: Record; +}; + +export function ChannelUserInputCard({ + item, + currentPubkey, + profiles, + sent = false, + onSubmit, + onSkip, +}: Props) { + const [state, setState] = React.useState>({}); + const ownerPubkey = profiles?.[item.event.pubkey]?.ownerPubkey ?? null; + const readOnly = ownerPubkey !== null && ownerPubkey !== currentPubkey; + const hasAnswer = item.request.questions.every((question) => { + const value = state[question.id]; + return Boolean(value?.custom.trim() || value?.selected.length); + }); + + const update = (id: string, patch: Partial) => { + setState((current) => ({ + ...current, + [id]: { + ...(current[id] ?? { selected: [], custom: "", notes: {} }), + ...patch, + }, + })); + }; + + const submit = async () => { + const answers: Record = {}; + for (const question of item.request.questions) { + const value = state[question.id] ?? { + selected: [], + custom: "", + notes: {}, + }; + if (value.custom.trim()) { + answers[question.id] = value.custom.trim(); + } else if (question.allow_notes && Object.keys(value.notes).length > 0) { + answers[question.id] = { + selected: question.multi_select ? value.selected : value.selected[0], + choice_notes: value.notes, + }; + } else { + answers[question.id] = question.multi_select + ? value.selected + : value.selected[0]; + } + } + await onSubmit(item, answers); + }; + + return ( + + + + {sent ? "Answer sent" : "Agent question"} + + {item.request.message ? ( + + {item.request.message} + + ) : null} + {readOnly ? ( + + Only the agent's owner can answer this question. + + ) : sent ? ( + Sent, waiting for the agent. + ) : null} + + {!sent ? ( + + {item.request.questions.map((question) => { + const value = state[question.id] ?? { + selected: [], + custom: "", + notes: {}, + }; + return ( +
+ + + {question.header} + + {question.question} + +
+ {question.options.map((option) => { + const checked = value.selected.includes(option.value); + return ( + + ); + })} +
+ {question.allow_custom_answer ? ( +