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
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