Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions apps/mobile/src/features/threads/QuestionAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ export function QuestionAttachments(props: {
<TextInputWrapper onPaste={paste}>
<TextInput
value={props.value}
multiline={props.question.initialAnswer !== undefined}
submitBehavior={props.question.initialAnswer !== undefined ? "newline" : undefined}
editable={!props.disabled}
onChangeText={props.onChangeText}
onFocus={() => props.onInputFocusChange?.(true)}
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2011,6 +2011,29 @@ const multiSelectQuestion = {
} as const;

describe("pending user input answers", () => {
it("preserves exact editor text, including a deliberately cleared answer", () => {
const question = { ...singleSelectQuestion, initialAnswer: " Proposed message\n" };
expect(
buildPendingUserInputAnswers([question], {
runtime: { customAnswer: question.initialAnswer },
}),
).toEqual({ runtime: question.initialAnswer });
expect(
buildPendingUserInputAnswers([question], {
runtime: { customAnswer: " Edited message\n\n" },
}),
).toEqual({ runtime: " Edited message\n\n" });
expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: "" } })).toEqual({
runtime: "",
});
expect(buildPendingUserInputAnswers([question], { runtime: { customAnswer: " \n" } })).toEqual({
runtime: " \n",
});
expect(setPendingUserInputCustomAnswer(question, { selectedOptionValues: ["Go"] }, "")).toEqual(
{ customAnswer: "" },
);
});

it("replaces single-select options and toggles multi-select options", () => {
expect(
togglePendingUserInputOptionSelection(
Expand Down
16 changes: 10 additions & 6 deletions apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,21 +399,25 @@ function resolvePendingUserInputAnswer(
): string | ReadonlyArray<string> | null {
if (draft?.attachmentsBlocked) return null;
const customAnswer =
question.allowCustomAnswer === false ? null : normalizeDraftAnswer(draft?.customAnswer);
if (customAnswer) {
question.allowCustomAnswer === false
? null
: question.initialAnswer !== undefined
? (draft?.customAnswer ?? null)
: normalizeDraftAnswer(draft?.customAnswer);
if (customAnswer !== null && customAnswer.length > 0) {
return customAnswer;
}

const selectedOptionValues = normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
if (question.multiSelect) {
return selectedOptionValues.length > 0
? selectedOptionValues
: question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0
? ""
: null;
: (customAnswer ??
(question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null));
}
return (
selectedOptionValues[0] ??
customAnswer ??
(question.allowCustomAnswer !== false && (draft?.attachmentCount ?? 0) > 0 ? "" : null)
);
}
Expand Down Expand Up @@ -1633,7 +1637,7 @@ export function setPendingUserInputCustomAnswer(
}

const selectedOptionValues =
customAnswer.trim().length > 0
question.initialAnswer !== undefined || customAnswer.trim().length > 0
? undefined
: normalizeSelectedOptionValues(question, draft?.selectedOptionValues);
return {
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/state/use-selected-thread-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/c
import {
type PendingThreadRequests,
type ThreadUserInputQuestion,
seedUserInputDraftAnswers,
} from "@t3tools/client-runtime/state/thread-requests";
import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
import { Atom } from "effect/reactivity";
Expand Down Expand Up @@ -104,6 +105,19 @@ export function useSelectedThreadRequests() {
const activePendingApproval = activePendingApprovals[0] ?? null;
const activePendingUserInputs = pendingRequests?.userInputs ?? EMPTY_PENDING_REQUESTS.userInputs;
const activePendingUserInput = activePendingUserInputs[0] ?? null;
useEffect(() => {
if (!activePendingUserInput || !selectedThreadShell) return;
const requestKey = scopedRequestKey(
selectedThreadShell.environmentId,
activePendingUserInput.requestId,
);
const existing = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
const drafts = existing[requestKey] ?? {};
const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts);
if (seeded !== drafts) {
appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...existing, [requestKey]: seeded });
}
}, [activePendingUserInput, selectedThreadShell]);
const questionServerConfigs = useServerConfigs();
const attachmentDrafts = useAtomValue(composerDraftsAtom);
const preparationCounts = useAtomValue(questionAttachmentPreparationAtom);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ const question = Schema.Struct({
),
multiSelect: Schema.optional(Schema.Boolean),
allowCustomAnswer: Schema.optional(Schema.Boolean),
initialAnswer: Schema.optional(Schema.String),
required: Schema.optional(Schema.Boolean),
});
const pendingRequest = Schema.Struct({
Expand Down
59 changes: 59 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,65 @@ describe("PiAdapterV2", () => {
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("keeps the complete editor prefill editable and returns exact edited text", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
const { runtime, takeEvent } = yield* openRuntime(fake);
yield* runtime.ensureThread({
threadId: THREAD_ID,
modelSelection: modelSelection("default"),
runtimePolicy,
});
const prefill = ` fix(server): retain full editor content\n\n${"detail\n".repeat(500)} `;
yield* fake.emit({
type: "extension_ui_request",
id: "ui-editor",
method: "editor",
title: "Commit message",
prefill,
});
const event = yield* takeEvent(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "user_input_request",
);
if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request")
return;
assert.equal(event.turnItem.questions[0]?.initialAnswer, prefill);
assert.deepEqual(event.turnItem.questions[0]?.options, []);
assert.equal(event.turnItem.questions[0]?.question, "Commit message");
const edited = " fix(server): edited commit message\n\n";
yield* runtime.respondToRuntimeRequest({
requestId: event.turnItem.requestId,
answers: { "ui-editor": edited },
});
const response = yield* fake.takeRequest("extension_ui_response");
assert.equal(response["value"], edited);
assert.isUndefined(response["cancelled"]);
yield* fake.emit({
type: "extension_ui_request",
id: "ui-editor-cleared",
method: "editor",
title: "Commit message",
prefill,
});
const cleared = yield* takeEvent(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "user_input_request" &&
event.turnItem.questions.some((question) => question.id === "ui-editor-cleared"),
);
if (cleared.type !== "turn_item.updated" || cleared.turnItem.type !== "user_input_request")
return;
yield* runtime.respondToRuntimeRequest({
requestId: cleared.turnItem.requestId,
answers: { "ui-editor-cleared": "" },
});
const emptyResponse = yield* fake.takeRequest("extension_ui_response");
assert.equal(emptyResponse["value"], "");
assert.isUndefined(emptyResponse["cancelled"]);
}).pipe(Effect.scoped, Effect.provide(layerTest)),
);

it.effect("raises bridge edit confirmations as file-change approvals", () =>
Effect.gen(function* () {
const fake = yield* makeFakePi;
Expand Down
26 changes: 11 additions & 15 deletions apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2912,25 +2912,21 @@ function piQuestion(
? event["options"]
.filter((option): option is string => typeof option === "string")
.map((option) => ({ label: option || "Empty value", description: option, value: option }))
: [
{
label: "Submit empty value",
description: "Send an empty string to the extension.",
value: "",
},
];
// The user-input contract has no prefill field, so an editor dialog's
// prefill is surfaced inside the question text; without it the user would
// edit blind against content they cannot see.
const prefill = method === "editor" ? recordString(event, "prefill") : undefined;
: method === "editor"
? []
: [
{
label: "Submit empty value",
description: "Send an empty string to the extension.",
value: "",
},
];
const question = recordString(event, "message") ?? recordString(event, "placeholder") ?? title;
return {
id: questionId,
header: title,
question:
prefill === undefined || prefill.length === 0
? question
: `${question}\n\nCurrent value:\n${prefill.slice(0, 2_000)}`,
question,
...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
options,
};
}
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ import {
togglePendingUserInputOptionSelection,
type PendingUserInputDraftAnswer,
} from "../pendingUserInput";
import { seedUserInputDraftAnswers } from "@t3tools/client-runtime/state/thread-requests";
import { useUiStateStore } from "../uiStateStore";
import { useWorkspaceMutationRefresh } from "../hooks/useWorkspaceMutationRefresh";
import {
Expand Down Expand Up @@ -3312,6 +3313,19 @@ export default function ChatView(props: ChatViewProps) {
activeThreadId,
activePendingUserInput?.requestId,
]);
const activePendingAnswerDrafts =
pendingUserInputAnswersByRequestId[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS;
if (
activePendingUserInput &&
seedUserInputDraftAnswers(activePendingUserInput.questions, activePendingAnswerDrafts) !==
activePendingAnswerDrafts
) {
setPendingUserInputAnswersByRequestId((existing) => {
const drafts = existing[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS;
const seeded = seedUserInputDraftAnswers(activePendingUserInput.questions, drafts);
return seeded === drafts ? existing : { ...existing, [activePendingRequestKey]: seeded };
});
}
const pendingQuestionDraftKeys = useMemo(
() =>
activeThreadId
Expand Down Expand Up @@ -10294,6 +10308,7 @@ export default function ChatView(props: ChatViewProps) {
[questionId]: setPendingUserInputCustomAnswer(
existing[activePendingRequestKey]?.[questionId],
value,
question,
),
},
}));
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export type {

/**
* The composer editor. Tiptap in both modes: the `richTextEnabled` setting
* toggles Markdown styling, never the engine. Plain mode renders every
* marker as a literal character and serializes byte-identically.
* toggles Markdown styling, never the engine. Plain mode keeps Markdown
* markers literal; `literalText` also preserves context-token sources as text.
*/
export function ComposerPromptEditor(props: ComposerPromptEditorProps) {
return <ComposerPromptEditorTiptap {...props} />;
Expand Down
Loading
Loading