Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,15 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
);
})}
</View>
{question.allowCustomAnswer !== false ? (
{question.companionText?.description ? (
<Text
className="font-sans text-sm leading-5 text-foreground-muted"
style={proseDirectionStyle(question.companionText.description, setDirection)}
>
{question.companionText.description}
</Text>
) : null}
{question.allowCustomAnswer !== false || question.companionText ? (
<QuestionAttachments
key={`${props.pendingUserInput.requestId}:${question.id}`}
requestId={props.pendingUserInput.requestId}
Expand Down
21 changes: 17 additions & 4 deletions apps/mobile/src/features/threads/QuestionAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export function QuestionAttachments(props: {
}) {
const { selectedThread } = useThreadSelection();
const navigation = useNavigation();
// A choice's companion text is typed here and sent under its own id, without attachments.
const companion = props.question.companionText;
const [previewFile, setPreviewFile] = useState<FilePreviewSource | null>(null);
const [previewVideo, setPreviewVideo] = useState<VideoPreviewSource | null>(null);
const configs = useServerConfigs();
Expand Down Expand Up @@ -97,7 +99,13 @@ export function QuestionAttachments(props: {
};
const paste = useNativePaste((uris) => {
const scope = pickerScope.current;
if (!selectedThread || props.disabled || !configs.get(selectedThread.environmentId)) return;
if (
!selectedThread ||
props.disabled ||
companion !== undefined ||
!configs.get(selectedThread.environmentId)
)
return;
const key = questionAttachmentDraftKey(
selectedThread.environmentId,
selectedThread.id,
Expand Down Expand Up @@ -126,10 +134,11 @@ export function QuestionAttachments(props: {
)
.finally(() => changeQuestionAttachmentPreparation(key, -1));
});
if (!selectedThread || props.question.allowCustomAnswer === false) return null;
if (!selectedThread || (props.question.allowCustomAnswer === false && companion === undefined))
return null;
const { environmentId, id: threadId } = selectedThread;
const capabilities = configs.get(environmentId)?.environment.capabilities;
const canAttach = capabilities !== undefined;
const canAttach = capabilities !== undefined && companion === undefined;
const key = questionAttachmentDraftKey(
environmentId,
threadId,
Expand Down Expand Up @@ -225,7 +234,11 @@ export function QuestionAttachments(props: {
onInputFocusChange?.(false);
}}
placeholder={
props.question.options.length === 0 ? "Type your answer" : "Or type a custom answer"
companion
? companion.header
: props.question.options.length === 0
? "Type your answer"
: "Or type a custom answer"
}
className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground"
/>
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,15 @@ const question = Schema.Struct({
allowCustomAnswer: Schema.optional(Schema.Boolean),
initialAnswer: Schema.optional(Schema.String),
required: Schema.optional(Schema.Boolean),
// Answered under its own id, alongside the question's own answer.
companionText: Schema.optional(
Schema.Struct({
id: Schema.String,
header: Schema.String,
description: Schema.optional(Schema.String),
required: Schema.optional(Schema.Boolean),
}),
),
});
const PendingRequestTool = Tool.make("t3_pending_request", {
...commandTool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1892,11 +1892,12 @@ describe("AcpAdapterV2", () => {
options: [{ label: "Pro", description: "Pro", value: "pro" }],
allowCustomAnswer: false,
required: true,
// The optional note after the choice is asked in the same step.
companionText: { id: "note", header: "Note (optional)", required: false },
},
{ id: "note", header: "Note (optional)", question: "Note", options: [], required: false },
],
answers: { count: "3", plan: "pro" },
result: { action: "accept", content: { count: 3, plan: "pro" } },
answers: { count: "3", plan: "pro", note: "Yearly" },
result: { action: "accept", content: { count: 3, plan: "pro", note: "Yearly" } },
},
])("Droid elicitation $scenario", ({ schema, questions, answers, result, withAttachments }) =>
Effect.gen(function* () {
Expand Down
41 changes: 26 additions & 15 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import {
import { modelSelectionsEqual } from "@t3tools/shared/model";
import { modelSwitchSince } from "./modelSwitch.ts";
import { recordThreadOpen } from "@t3tools/shared/paletteSearch";
import { userInputAnswerError } from "@t3tools/shared/userInputConstraints";
import { userInputAnswersError } from "@t3tools/shared/userInputConstraints";
import {
backgroundWorkHoldsCompletion,
derivePendingBackgroundWork,
Expand Down Expand Up @@ -9746,15 +9746,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
]),
);
const questions = context.item?.type === "user_input_request" ? context.item.questions : [];
for (const question of questions) {
const error = userInputAnswerError(question, normalized[question.id]);
if (error !== null) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
cause: error,
});
}
const error = userInputAnswersError(questions, normalized);
if (error !== null) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
cause: error,
});
}
answers = normalized;
}
Expand Down Expand Up @@ -9793,7 +9791,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
cause: "The provider for this project is disabled.",
});
}
const questionIds = new Set(approvalTurnItem.questions.map((question) => question.id));
// A companion text is answered under its own id, right after its question.
const prompts = approvalTurnItem.questions.flatMap((question) => [
{ id: question.id, prompt: question.question, optional: question.required === false },
...(question.companionText === undefined
? []
: [
{
id: question.companionText.id,
prompt: question.companionText.header,
optional: question.companionText.required !== true,
},
]),
]);
const questionIds = new Set(prompts.map((prompt) => prompt.id));
if (
!Object.entries(answers ?? {}).every(
([questionId, answer]) =>
Expand All @@ -9806,11 +9817,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
cause: "Answer each question before sending.",
});
}
for (const question of approvalTurnItem.questions) {
const answer = answers?.[question.id];
for (const { id, prompt, optional } of prompts) {
const answer = answers?.[id];
const answered = isMessageQuestionAnswer(answer);
// Clients send "" for a skipped optional question, such as an attachment-only answer.
if (!answered && question.required === false) continue;
if (!answered && optional) continue;
if (!answered) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
Expand All @@ -9819,7 +9830,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});
}
const text = typeof answer === "string" ? answer : answer.join(", ");
replies.push(`${question.question}\n${text.trim()}`);
replies.push(`${prompt}\n${text.trim()}`);
}
if (replies.length === 0) {
return yield* new OrchestratorDispatchError({
Expand Down
26 changes: 13 additions & 13 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ export interface ChatComposerProps {
initialAnswer?: string | undefined;
multiSelect?: boolean | undefined;
allowCustomAnswer?: boolean | undefined;
companionText?: { readonly header: string } | undefined;
options: ReadonlyArray<unknown>;
} | null;
} | null;
Expand Down Expand Up @@ -1204,8 +1205,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const composerSuggestionsVisible = composerMenuOpen;
const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0;
const activePendingUserInput = pendingUserInputs[0] ?? null;
// A choice with companion text still takes the composer's text, as that companion's answer.
const isChoiceOnlyPendingQuestion =
activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
activePendingProgress?.activeQuestion?.allowCustomAnswer === false &&
activePendingProgress.activeQuestion.companionText === undefined;
const showComposerTopDrawer =
pendingUserInputs.length > 0 ||
(!isComposerCollapsedMobile && showPlanFollowUpPrompt && activeProposedPlan !== null);
Expand Down Expand Up @@ -1611,7 +1614,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
) => {
refreshWorkspaceSnapshotOnTyping();
if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) {
if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return;
if (isChoiceOnlyPendingQuestion) return;
setComposerCursor(nextCursor);
setComposerTrigger(
cursorAdjacentToMention ? null : detectComposerTrigger(nextPrompt, expandedCursor),
Expand Down Expand Up @@ -1757,10 +1760,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
};
},
): boolean => {
if (
activePendingUserInput &&
activePendingProgress?.activeQuestion?.allowCustomAnswer === false
) {
if (activePendingUserInput && isChoiceOnlyPendingQuestion) {
return false;
}
const currentText = promptRef.current;
Expand Down Expand Up @@ -1849,7 +1849,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
projectSelectionRequired ||
isComposerApprovalState ||
activePendingIsResponding ||
activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
isChoiceOnlyPendingQuestion;
const speechInput = useEnvironmentSpeechInput({
environmentId,
projectId: activeProjectId,
Expand Down Expand Up @@ -2807,9 +2807,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
if (records.length === 0) return;
if (questionAttachmentTarget !== null) {
const canAnswer =
canOperateThread &&
!activePendingIsResponding &&
activePendingProgress?.activeQuestion?.allowCustomAnswer !== false;
canOperateThread && !activePendingIsResponding && !isChoiceOnlyPendingQuestion;
const text = `${records
.map((record) => formatInlineContextReference(threadContextReference(record)))
.join(" ")} `;
Expand Down Expand Up @@ -3530,9 +3528,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
: activePendingProgress
? isChoiceOnlyPendingQuestion
? "Choose an option above"
: activePendingProgress.activeQuestion?.options.length === 0
? "Type your answer"
: "Type your own answer, or leave this blank to use the selected option"
: activePendingProgress.activeQuestion?.companionText
? activePendingProgress.activeQuestion.companionText.header
: activePendingProgress.activeQuestion?.options.length === 0
? "Type your answer"
: "Type your own answer, or leave this blank to use the selected option"
: showPlanFollowUpPrompt && activeProposedPlan
? "Add feedback to refine the plan, or leave this blank to implement it"
: projectSelectionRequired
Expand Down
16 changes: 14 additions & 2 deletions apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
// Keep question identity stable for its shortcut listener.
const progress = derivePendingUserInputProgress(prompt.questions, answers, questionIndex);
const activeQuestion = progress.activeQuestion;
const customAnswerActive = progress.customAnswer.trim().length > 0;
// Companion text sits beside the chosen option; a custom answer replaces it.
const companionText = activeQuestion?.companionText;
const customAnswerActive = companionText === undefined && progress.customAnswer.trim().length > 0;
// Read before later hooks to preserve the question's compiler cache.
const draftSelectedOptions = activeQuestion?.options.map(
(option) =>
Expand Down Expand Up @@ -129,7 +131,9 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(

const handleOptionSelection = (questionId: string, optionValue: string) => {
if (disabled || isResponding) return;
if (activeQuestion?.multiSelect) {
// The companion text is typed after picking, so that step waits for an explicit submit, and
// picking the chosen option again clears it.
if (activeQuestion?.multiSelect || companionText !== undefined) {
onToggleOption(questionId, optionValue);
return;
}
Expand Down Expand Up @@ -326,6 +330,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
);
})}
</div>
{companionText?.description ? (
<p
className={cn("mt-1 pb-1 text-secondary-label text-2xs", questionTextAlign)}
dir={markdownProseDirection(companionText.description)}
>
{companionText.description}
</p>
) : null}
</ComposerBanner.Body>
</ComposerBanner.Scroll>
</CollapsiblePanel>
Expand Down
13 changes: 11 additions & 2 deletions apps/web/src/components/chat/usePendingThreadRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useStableCallback } from "../../hooks/useStableCallback";
import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue";
import {
buildPendingUserInputAnswers,
pendingUserInputTakesText,
setPendingUserInputCustomAnswer,
togglePendingUserInputOptionSelection,
type PendingUserInputDraftAnswer,
Expand Down Expand Up @@ -93,7 +94,7 @@ function makePendingCustomAnswerSetter(input: {
return;
}
const question = activePendingUserInput.questions.find((entry) => entry.id === questionId);
if (!question || question.allowCustomAnswer === false) {
if (!question || !pendingUserInputTakesText(question)) {
return;
}
input.writePrompt(value);
Expand Down Expand Up @@ -397,9 +398,16 @@ export function usePendingThreadRequests(input: {
if (!activePendingUserInput) {
return;
}
const selectedQuestion = activePendingUserInput.questions.find(
(entry) => entry.id === questionId,
);
// A companion text stays beside the option, so the composer keeps it.
const keepsText = selectedQuestion?.companionText !== undefined;
// The option replaces the custom answer. Anything typed there is the
// user's text, so it goes back to the thread draft instead of vanishing.
const displacedAnswer = activePendingAnswerDrafts[questionId]?.customAnswer;
const displacedAnswer = keepsText
? undefined
: activePendingAnswerDrafts[questionId]?.customAnswer;
const currentPrompt =
useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? "";
const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer);
Expand Down Expand Up @@ -450,6 +458,7 @@ export function usePendingThreadRequests(input: {
},
};
});
if (keepsText) return;
promptRef.current = "";
composerRef.current?.resetCursorState({ cursor: 0 });
};
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/pendingUserInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,35 @@ describe("pending user input question progress", () => {
});
});

it("asks a choice and its companion text as one step", () => {
const paired = {
...singleSelectQuestion,
allowCustomAnswer: false,
required: false,
companionText: { id: "note", header: "Your own answer (optional)", required: false },
};
const drafts = { scope: { selectedOptionValues: ["Orchestration-first"], customAnswer: "Soon" } };
expect(derivePendingUserInputProgress([paired], drafts, 0)).toMatchObject({
questionIndex: 0,
selectedOptionValues: ["Orchestration-first"],
customAnswer: "Soon",
resolvedAnswer: "Orchestration-first",
usingCustomAnswer: false,
answeredQuestionCount: 1,
isLastQuestion: true,
isComplete: true,
canAdvance: true,
});
expect(buildPendingUserInputAnswers([paired], drafts)).toEqual({
scope: "Orchestration-first",
note: "Soon",
});
// Text alone is a complete answer too.
expect(
derivePendingUserInputProgress([paired], { scope: { customAnswer: "Mine" } }, 0),
).toMatchObject({ customAnswer: "Mine", isLastQuestion: true, isComplete: true });
});

it("lets optional questions stay blank without skipping required ones", () => {
const questions = [
{ ...singleSelectQuestion, required: false },
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/pendingUserInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { formatThreadLink } from "@t3tools/shared/threadLinks";
import {
buildPendingUserInputAnswers,
normalizeSelectedOptionValues,
pendingUserInputTakesText,
resolvePendingUserInputAnswer,
type PendingUserInputAnswer,
type PendingUserInputDraftAnswer,
Expand Down Expand Up @@ -107,8 +108,11 @@ export function derivePendingUserInputProgress(
const resolvedAnswer = activeQuestion
? resolvePendingUserInputAnswer(activeQuestion, activeDraft)
: null;
// A choice's companion text is typed in the composer like a custom answer.
const customAnswer =
activeQuestion?.allowCustomAnswer === false ? "" : (activeDraft?.customAnswer ?? "");
activeQuestion && pendingUserInputTakesText(activeQuestion)
? (activeDraft?.customAnswer ?? "")
: "";
const answeredQuestionCount = countAnsweredPendingUserInputQuestions(questions, draftAnswers);
const isLastQuestion =
questions.length === 0 ? true : normalizedQuestionIndex >= questions.length - 1;
Expand All @@ -123,6 +127,7 @@ export function derivePendingUserInputProgress(
customAnswer,
resolvedAnswer,
usingCustomAnswer:
activeQuestion?.companionText === undefined &&
(activeQuestion?.initialAnswer !== undefined ? customAnswer : customAnswer.trim()).length > 0,
answeredQuestionCount,
isLastQuestion,
Expand Down
Loading
Loading