Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ vi.mock(import("../ChatMarkdown"), async (importOriginal) => ({
}));

import { act, fakeTimers, render } from "../../../test/render";
import { DraftId, useComposerDraftStore } from "../../composerDraftStore";
import { makeThreadProjectionFixture } from "../../test-fixtures";
import { usePendingThreadRequests } from "./usePendingThreadRequests";

Expand Down Expand Up @@ -78,10 +79,12 @@ function PendingRequestsProbe({
environmentId,
projection,
loading = false,
composerDraftTarget,
}: {
environmentId: EnvironmentId;
projection: OrchestrationV2ThreadProjection;
loading?: boolean;
composerDraftTarget?: DraftId;
}) {
const promptRef = useRef("");
const composerRef = useRef(null);
Expand All @@ -91,8 +94,9 @@ function PendingRequestsProbe({
activeThread: projection.thread,
serverProjection: loading ? null : projection,
supportsQuestionAttachments: false,
composerDraftTarget: { environmentId, threadId: projection.thread.id },
setComposerDraftPrompt: () => {},
composerDraftTarget: composerDraftTarget ?? { environmentId, threadId: projection.thread.id },
setComposerDraftPrompt: (target, prompt) =>
useComposerDraftStore.getState().setPrompt(target, prompt),
promptRef,
composerRef,
setThreadError: () => {},
Expand Down Expand Up @@ -181,3 +185,100 @@ it("isolates drafts by environment, thread and request and discards only resolve
);
}
});

it("keeps a typed answer in the thread draft when the question is cancelled by Stop", async () => {
using _timers = fakeTimers();
const environmentId = EnvironmentId.make("stop-environment");
const projection = questionProjection(threadRef.threadId, ["first"]);
const answer = "Purple please. This typed question answer must survive Stop.";
const draftTarget = { environmentId, threadId: threadRef.threadId };
useComposerDraftStore.getState().setPrompt(draftTarget, "Earlier draft");
await using screen = await render(
<PendingRequestsProbe environmentId={environmentId} projection={projection} />,
);
await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", answer));
expect(requests.activePendingDraftAnswers["question-0"]?.customAnswer).toBe(answer);

// Stop interrupts the turn, which cancels its open question without an answer.
await screen.rerender(
<PendingRequestsProbe
environmentId={environmentId}
projection={{
...projection,
runtimeRequests: projection.runtimeRequests.map((request) => ({
...request,
status: "cancelled",
resolvedAt: projection.updatedAt,
})),
}}
/>,
);
expect(requests.activePendingUserInput).toBeNull();
expect(useComposerDraftStore.getState().getComposerDraft(draftTarget)?.prompt).toBe(
`Earlier draft\n\n${answer}`,
);
});

it("does not return an answer to the thread draft once the question is resolved", async () => {
using _timers = fakeTimers();
const environmentId = EnvironmentId.make("resolve-environment");
const projection = questionProjection(threadRef.threadId, ["first"]);
await using screen = await render(
<PendingRequestsProbe environmentId={environmentId} projection={projection} />,
);
await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", "Sent"));
await screen.rerender(
<PendingRequestsProbe
environmentId={environmentId}
projection={{
...projection,
runtimeRequests: projection.runtimeRequests.map((request) => ({
...request,
status: "resolved",
resolvedAt: projection.updatedAt,
})),
}}
/>,
);
expect(
useComposerDraftStore
.getState()
.getComposerDraft({ environmentId, threadId: threadRef.threadId })?.prompt ?? "",
).toBe("");
});

it("keeps a cancelled question's typed answer when a queued-message edit is open and then cancelled", async () => {
using _timers = fakeTimers();
const environmentId = EnvironmentId.make("queued-edit-environment");
const projection = questionProjection(threadRef.threadId, ["first"]);
const answer = "Typed while a queued message was being edited.";
const threadTarget = { environmentId, threadId: threadRef.threadId };
// The composer shows the queued edit's temporary draft, not the thread's.
const editTarget = DraftId.make("queued-edit:test:run");
useComposerDraftStore.getState().setPrompt(editTarget, "Queued message text");
await using screen = await render(
<PendingRequestsProbe
environmentId={environmentId}
projection={projection}
composerDraftTarget={editTarget}
/>,
);
await act(() => requests.onChangeActivePendingUserInputCustomAnswer("question-0", answer));
await screen.rerender(
<PendingRequestsProbe
environmentId={environmentId}
composerDraftTarget={editTarget}
projection={{
...projection,
runtimeRequests: projection.runtimeRequests.map((request) => ({
...request,
status: "cancelled",
resolvedAt: projection.updatedAt,
})),
}}
/>,
);
// Cancelling the queued edit clears its temporary draft.
useComposerDraftStore.getState().clearComposerContent(editTarget);
expect(useComposerDraftStore.getState().getComposerDraft(threadTarget)?.prompt).toBe(answer);
});
45 changes: 44 additions & 1 deletion apps/web/src/components/chat/usePendingThreadRequests.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { ThreadStructure } from "@t3tools/client-runtime/state/shell";
import {
AuthOrchestrationOperateScope,
type EnvironmentId,
type ProviderApprovalDecision,
type RuntimeRequestId,
type ScopedThreadRef,
type ThreadContextRecord,
type ThreadId,
type UserInputAttachments,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -217,12 +219,53 @@ export function usePendingThreadRequests(input: {
Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])),
),
);
// A question that closes without an answer (Stop cancels it) never sent the
// user's typed text, so it goes back to the thread draft instead of vanishing.
// The composer may be showing a temporary queued-message edit, so the text
// goes to the owning thread's own draft, which outlives that edit.
const carryUnsentCustomAnswers = useStableCallback((threadId: ThreadId) => {
if (serverProjection?.thread.id !== threadId) return;
const drafts = usePendingUserInputAnswers.getState();
const store = useComposerDraftStore.getState();
const threadDraftTarget = scopeThreadRef(environmentId, threadId);
const currentPrompt = store.getComposerDraft(threadDraftTarget)?.prompt ?? "";
let nextPrompt = currentPrompt;
const threadContexts: ThreadContextRecord[] = [];
for (const request of serverProjection.runtimeRequests) {
if (request.kind !== "user_input") continue;
if (request.status !== "cancelled" && request.status !== "expired") continue;
const requestDrafts = drafts[pendingUserInputRequestKey(environmentId, threadId, request.id)];
if (!requestDrafts) continue;
const item = serverProjection.turnItems.findLast(
(candidate) =>
candidate.type === "user_input_request" && candidate.requestId === request.id,
);
if (item?.type !== "user_input_request") continue;
for (const question of item.questions) {
const customAnswer = requestDrafts[question.id]?.customAnswer;
if (customAnswer === undefined || customAnswer === question.initialAnswer) continue;
nextPrompt = carryDisplacedCustomAnswerIntoPrompt(nextPrompt, customAnswer);
threadContexts.push(
...threadContextsReferencedByAnswer(
customAnswer,
store.getComposerDraft(
questionAttachmentDraftId(environmentId, threadId, request.id, question.id),
)?.threadContexts ?? [],
),
);
}
}
if (nextPrompt === currentPrompt) return;
setComposerDraftPrompt(threadDraftTarget, nextPrompt);
store.addThreadContexts(threadDraftTarget, threadContexts, { appendReference: false });
});
useEffect(() => {
// A thread shell can arrive before its requests; missing detail is not resolution.
if (!activeThread || projectedThreadId !== activeThread.id) return;
const questionThread = activeThread;
const currentRequests = pendingUserInputs;
const pendingRequestIds = currentRequests.map((request) => request.requestId);
carryUnsentCustomAnswers(questionThread.id);
usePendingUserInputAnswers.setState(
(existing) =>
pruneResolvedUserInputDrafts(existing, environmentId, questionThread.id, pendingRequestIds),
Expand Down Expand Up @@ -254,7 +297,7 @@ export function usePendingThreadRequests(input: {
if (key.startsWith(prefix) && !retained.has(DraftId.make(key)))
clearQuestionAttachmentDraft(DraftId.make(key));
}
}, [environmentId, activeThread, projectedThreadId, pendingUserInputs]);
}, [environmentId, activeThread, projectedThreadId, pendingUserInputs, carryUnsentCustomAnswers]);
const activePendingDraftAnswers =
!activePendingUserInput || !activeThreadId
? EMPTY_PENDING_USER_INPUT_ANSWERS
Expand Down
186 changes: 186 additions & 0 deletions scripts/fixtures/issue-8963-client-probe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { createElement as h, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { makeThreadProjectionFixture } from "/src/test-fixtures.ts";
import { useComposerDraftStore } from "/src/composerDraftStore.ts";
import { usePendingThreadRequests } from "/src/components/chat/usePendingThreadRequests.ts";
import fixture from "./issue-8963.json" with { type: "json" };

// This is the navigation browser-test probe's projection boundary, not ChatView
// or a provider emulator. Its status buttons never call the server.
export function mountProbe(scenario, theme = "dark") {
if (!["dark", "light"].includes(theme)) throw new Error("Unknown theme");
const background = theme === "dark" ? "#0a0a0a" : "#fff";
const foreground = theme === "dark" ? "#eee" : "#111";
document.documentElement.style.backgroundColor = background;
document.body.style.backgroundColor = background;
if (!["R4", "R6", "R8"].includes(scenario)) throw new Error("Unknown scenario");
const environmentId = `proof-probe-${scenario}`;
const threadId = `proof-thread-${scenario}`;
const target = { environmentId, threadId };
const ids = scenario === "R6" ? fixture.r6.requestIds : [`proof-${scenario}`];
const template = makeThreadProjectionFixture();
const initial = {
...template,
thread: { ...template.thread, id: threadId, title: "Proof A" },
runtimeRequests: ids.map((id) => ({
id,
nodeId: id,
providerTurnId: null,
nativeRequestRef: null,
kind: "user_input",
status: "pending",
responseCapability: { type: "message" },
createdAt: template.updatedAt,
resolvedAt: null,
})),
turnItems: ids.map((id, ordinal) => ({
id,
threadId,
runId: null,
nodeId: id,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal,
status: "completed",
title: null,
startedAt: template.updatedAt,
completedAt: template.updatedAt,
updatedAt: template.updatedAt,
type: "user_input_request",
requestId: id,
questions:
scenario === "R4"
? fixture.r4.questions
: [
{
id: "answer",
header: id,
question: "Type a custom answer",
options: [],
allowCustomAnswer: true,
},
],
})),
};
useComposerDraftStore.getState().clearComposerContent(target);
function Shell() {
const [projection, setProjection] = useState(initial);
const [mounted, setMounted] = useState(true);
return h(
"main",
null,
h(
"button",
{ onClick: () => setMounted(!mounted) },
mounted ? "Unmount hook probe" : "Remount hook probe",
),
mounted
? h(Probe, { projection, setProjection })
: h("p", null, "Hook probe unmounted · synthetic revisit only"),
);
}
function Probe({ projection, setProjection }) {
const [chosenRequest, setChosenRequest] = useState(ids[0]);
const promptRef = useRef("");
const composerRef = useRef(null);
const ordered = {
...projection,
runtimeRequests: [...projection.runtimeRequests].sort(
(a, b) => Number(b.id === chosenRequest) - Number(a.id === chosenRequest),
),
};
const state = usePendingThreadRequests({
environmentId,
activeThreadId: threadId,
activeThread: projection.thread,
serverProjection: ordered,
supportsQuestionAttachments: false,
composerDraftTarget: target,
setComposerDraftPrompt: (destination, prompt) =>
useComposerDraftStore.getState().setPrompt(destination, prompt),
promptRef,
composerRef,
setThreadError: () => {},
});
const draft = useComposerDraftStore((store) => store.getComposerDraft(target)?.prompt ?? "");
const progress = state.activePendingProgress;
const question = progress?.activeQuestion;
const close = () => {
setChosenRequest(ids[0]);
setProjection({
...projection,
runtimeRequests: projection.runtimeRequests.map((request) => ({
...request,
status: scenario === "R8" ? "expired" : "cancelled",
resolvedAt: template.updatedAt,
})),
});
};
return h(
"main",
null,
h("h1", null, `${scenario} · CLIENT HOOK PROBE`),
h(
"p",
null,
"Synthetic projections · usePendingThreadRequests · native textarea · not ChatView · no backend proof",
),
h("p", null, `Environment ${environmentId}; thread ${threadId}`),
scenario === "R6"
? h(
"nav",
{ "aria-label": "Fixture request navigation" },
ids.map((id) => h("button", { key: id, onClick: () => setChosenRequest(id) }, id)),
)
: null,
question
? h(
"section",
null,
h("h2", null, `${question.header}: ${question.question}`),
h("textarea", {
"aria-label": "Custom answer",
value: progress.customAnswer,
disabled: question.allowCustomAnswer === false,
onChange: (event) =>
state.onChangeActivePendingUserInputCustomAnswer(question.id, event.target.value),
}),
question.options.map((option) =>
h(
"button",
{
key: option.label,
onClick: () =>
state.onSelectActivePendingUserInputOption(question.id, option.label),
},
option.label,
),
),
!progress.isLastQuestion
? h("button", { onClick: state.onAdvanceActivePendingUserInput }, "Next question")
: null,
)
: h("p", null, "Question closed"),
h("button", { onClick: close }, scenario === "R8" ? "Project expired" : "Project cancelled"),
h("h2", null, "Ordinary thread draft"),
h("textarea", { "aria-label": "Ordinary draft", value: draft, readOnly: true }),
h(
"pre",
null,
JSON.stringify(
{ statuses: projection.runtimeRequests.map((r) => r.status), draft },
null,
2,
),
),
);
}
const host = document.createElement("div");
const style = document.createElement("style");
style.textContent = `html,body{background:${background};color:${foreground};color-scheme:${theme}}body{margin:0;font:16px system-ui}main{padding:32px;max-width:1000px;margin:auto}main main{padding:0}h1{font-size:24px}h2{font-size:18px}textarea{display:block;box-sizing:border-box;width:100%;height:120px;padding:12px;background:${theme === "dark" ? "#222" : "#fff"};color:${foreground};border:1px solid #777;font:16px monospace}button{padding:10px 16px;margin:12px 12px 12px 0;background:#eee;color:#111;border:0;border-radius:6px}pre{white-space:pre-wrap}`;
document.head.append(style);
document.body.replaceChildren(host);
createRoot(host).render(h(Shell));
}
Loading
Loading