diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index 777238f7635e8..06d725f19cdf1 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -255,7 +255,10 @@ it("isolates drafts by environment, thread and request and discards only resolve makeThreadRequestsResolvedAnnouncer( environmentId, threadRef.threadId, - )([RuntimeRequestId.make("second")]), + )([ + { id: RuntimeRequestId.make("first"), status: "resolved" }, + { id: RuntimeRequestId.make("second"), status: "pending" }, + ]), ); for (const [index, scope] of scopes.entries()) { await show(scope); diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index d31d6e56643d7..b893a91aa0466 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -10,8 +10,6 @@ import { } from "@t3tools/contracts"; import { derivePendingThreadRequests, - onThreadRequestsResolved, - pruneResolvedUserInputDrafts, seedUserInputDraftAnswers, userInputDraftKey, } from "@t3tools/client-runtime/state/thread-requests"; @@ -19,7 +17,6 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { create } from "zustand"; import { useRef, useState, type RefObject, type SetStateAction } from "react"; import { useShallow } from "zustand/react/shallow"; import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; @@ -39,6 +36,8 @@ import { } from "../../pendingUserInput"; import { questionAttachmentDraftId, + usePendingUserInputAnswers, + usePendingUserInputQuestionIndex, useQuestionAttachmentPreparation, } from "../../questionAttachments"; import { @@ -55,30 +54,10 @@ import { toastManager } from "../ui/toast"; import type { ChatComposerHandle } from "./ChatComposer"; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; -const usePendingUserInputAnswers = create< - Record> ->(() => ({})); -const usePendingUserInputQuestionIndex = create>(() => ({})); const NO_PENDING_REQUESTS = { approvals: [], userInputs: [] } as const; const NO_PENDING_APPROVALS: ReturnType = []; const NO_PENDING_USER_INPUTS: ReturnType = []; -// Resolution can come from anywhere: another device, an interrupt, the turn -// ending, or a thread that is not open. Question attachment drafts are pruned -// by questionAttachments.ts, which owns them. -onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds }) => { - usePendingUserInputAnswers.setState( - (existing) => - pruneResolvedUserInputDrafts(existing, environmentId, threadId, pendingRequestIds), - true, - ); - usePendingUserInputQuestionIndex.setState( - (existing) => - pruneResolvedUserInputDrafts(existing, environmentId, threadId, pendingRequestIds), - true, - ); -}); - function makePendingCustomAnswerSetter(input: { readonly activePendingUserInput: PendingUserInput | null; readonly activePendingRequestKey: string; diff --git a/apps/web/src/questionAttachments.test.ts b/apps/web/src/questionAttachments.test.ts index 2b1120f23df1f..2ca477b4212e2 100644 --- a/apps/web/src/questionAttachments.test.ts +++ b/apps/web/src/questionAttachments.test.ts @@ -1,13 +1,20 @@ -import { makeThreadRequestsResolvedAnnouncer } from "@t3tools/client-runtime/state/thread-requests"; +import { + makeThreadRequestsResolvedAnnouncer, + userInputDraftKey, +} from "@t3tools/client-runtime/state/thread-requests"; import { ApprovalRequestId, EnvironmentId, RuntimeRequestId, ThreadId } from "@t3tools/contracts"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { beforeEach, expect, it, vi } from "vite-plus/test"; import { useComposerDraftStore } from "./composerDraftStore"; +import { formatInlineContextReference } from "./lib/composerContextReferences"; +import { threadContextReference } from "./lib/composerContextRecords"; import { questionAttachmentDraftId, countQuestionAttachments, questionAttachmentDraftPrefix, changeQuestionAttachmentPreparation, clearQuestionAttachmentDraft, + usePendingUserInputAnswers, useQuestionAttachmentPreparation, } from "./questionAttachments"; @@ -19,6 +26,7 @@ const requestId = ApprovalRequestId.make("request-1"); beforeEach(() => { useComposerDraftStore.setState({ draftsByThreadKey: {}, draftThreadsByThreadKey: {} }); useQuestionAttachmentPreparation.setState({ counts: {} }); + usePendingUserInputAnswers.setState({}, true); release.mockClear(); }); it("keeps question files separate from the normal draft and other questions", () => { @@ -126,13 +134,58 @@ it("clears attachment drafts for requests the thread resolved, wherever they wer const elsewhere = questionAttachmentDraftId(environmentId, otherThread, resolved, "first"); for (const key of [keep, drop, elsewhere]) store.setPrompt(key, "Answer context"); changeQuestionAttachmentPreparation(drop, 1); + usePendingUserInputAnswers.setState({ + [userInputDraftKey(environmentId, threadId, resolved)]: { first: { customAnswer: "Sent" } }, + }); // The thread's projection synchronizes with only `pending` left, e.g. after // `resolved` was answered on another device while no composer showed it. - makeThreadRequestsResolvedAnnouncer(environmentId, threadId)([pending]); + makeThreadRequestsResolvedAnnouncer( + environmentId, + threadId, + )([ + { id: pending, status: "pending" }, + { id: resolved, status: "resolved" }, + ]); expect(store.getComposerDraft(keep)?.prompt).toBe("Answer context"); expect(store.getComposerDraft(drop)).toBeNull(); expect(store.getComposerDraft(elsewhere)?.prompt).toBe("Answer context"); expect(useQuestionAttachmentPreparation.getState().counts[drop]).toBeUndefined(); + // An answered question's text was sent, so it does not come back as a draft. + expect(store.getComposerDraft({ environmentId, threadId })).toBeNull(); + expect(usePendingUserInputAnswers.getState()).toEqual({}); }); + +// Stop cancels an open question; a provider timeout expires it. Neither sends the answer. +it.each(["cancelled", "expired"] as const)( + "returns typed answers and their thread chips to the thread draft when a question is %s", + (status) => { + const store = useComposerDraftStore.getState(); + const request = RuntimeRequestId.make("stopped"); + const reference = threadContextRecord( + { environmentId, threadId: ThreadId.make("reference-thread") }, + "Reference work", + ); + const answer = `Purple, like ${formatInlineContextReference(threadContextReference(reference))}`; + store.setPrompt({ environmentId, threadId }, "Earlier draft"); + store.setThreadContexts(questionAttachmentDraftId(environmentId, threadId, request, "first"), [ + reference, + ]); + usePendingUserInputAnswers.setState({ + [userInputDraftKey(environmentId, threadId, request)]: { + first: { customAnswer: answer }, + second: { customAnswer: " " }, + third: { customAnswer: "Also this" }, + }, + }); + + makeThreadRequestsResolvedAnnouncer(environmentId, threadId)([{ id: request, status }]); + + expect(store.getComposerDraft({ environmentId, threadId })).toMatchObject({ + prompt: `Earlier draft\n\n${answer}\n\nAlso this`, + threadContexts: [reference], + }); + expect(usePendingUserInputAnswers.getState()).toEqual({}); + }, +); diff --git a/apps/web/src/questionAttachments.ts b/apps/web/src/questionAttachments.ts index 8305bb679bf64..712bd537b3ba0 100644 --- a/apps/web/src/questionAttachments.ts +++ b/apps/web/src/questionAttachments.ts @@ -1,12 +1,31 @@ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; import { onThreadRequestsResolved, + pruneResolvedUserInputDrafts, resolvedQuestionDraftKeys, + userInputDraftKey, } from "@t3tools/client-runtime/state/thread-requests"; -import type { EnvironmentId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { + EnvironmentId, + RuntimeRequestId, + ScopedThreadRef, + ThreadContextRecord, + ThreadId, +} from "@t3tools/contracts"; import { create } from "zustand"; import { DraftId, useComposerDraftStore } from "./composerDraftStore"; import { releaseDraftAttachments } from "./lib/attachmentUploadQueue"; +import { + carryDisplacedCustomAnswerIntoPrompt, + threadContextsReferencedByAnswer, +} from "./pendingUserInput"; + +/** Edited question answers, keyed by `userInputDraftKey`; suggested answers are not stored. */ +export const usePendingUserInputAnswers = create< + Record> +>(() => ({})); +export const usePendingUserInputQuestionIndex = create>(() => ({})); export function questionAttachmentDraftPrefix( environmentId: EnvironmentId, @@ -113,8 +132,58 @@ function pruneResolvedQuestionAttachmentDrafts( } } -// Attachment drafts persist across reloads, so a request resolved while this -// client was away is pruned when its thread's projection first synchronizes. -onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds }) => - pruneResolvedQuestionAttachmentDrafts(environmentId, threadId, pendingRequestIds), -); +/** + * Stop cancels an open question and a provider timeout expires it; neither sent + * the typed answers, so they move to the thread's own draft (not a queued-message + * edit the composer may be showing) with the thread chips they reference. + */ +function returnUnsentAnswersToThreadDraft( + environmentId: EnvironmentId, + threadId: ThreadId, + unansweredRequestIds: ReadonlyArray, +): void { + const answers = usePendingUserInputAnswers.getState(); + const store = useComposerDraftStore.getState(); + const target = scopeThreadRef(environmentId, threadId); + const currentPrompt = store.getComposerDraft(target)?.prompt ?? ""; + let prompt = currentPrompt; + const threadContexts: ThreadContextRecord[] = []; + for (const requestId of unansweredRequestIds) { + const drafts = answers[userInputDraftKey(environmentId, threadId, requestId)] ?? {}; + for (const [questionId, { customAnswer }] of Object.entries(drafts)) { + const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(prompt, customAnswer); + if (nextPrompt === prompt) continue; + prompt = nextPrompt; + const questionDraft = store.getComposerDraft( + questionAttachmentDraftId(environmentId, threadId, requestId, questionId), + ); + threadContexts.push( + ...threadContextsReferencedByAnswer( + customAnswer ?? "", + questionDraft?.threadContexts ?? [], + ), + ); + } + } + if (prompt === currentPrompt) return; + store.setPrompt(target, prompt); + store.addThreadContexts(target, threadContexts, { appendReference: false }); +} + +// Resolution can come from anywhere: another device, an interrupt, the turn +// ending, or a reload over drafts for requests resolved meanwhile. One listener, +// so unsent answers and their chips are read before their drafts are pruned. +onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds, unansweredRequestIds }) => { + returnUnsentAnswersToThreadDraft(environmentId, threadId, unansweredRequestIds); + usePendingUserInputAnswers.setState( + (existing) => + pruneResolvedUserInputDrafts(existing, environmentId, threadId, pendingRequestIds), + true, + ); + usePendingUserInputQuestionIndex.setState( + (existing) => + pruneResolvedUserInputDrafts(existing, environmentId, threadId, pendingRequestIds), + true, + ); + pruneResolvedQuestionAttachmentDrafts(environmentId, threadId, pendingRequestIds); +}); diff --git a/packages/client-runtime/src/state/threadRequests.test.ts b/packages/client-runtime/src/state/threadRequests.test.ts index c8c43f2334087..f6eb4e1f76cb1 100644 --- a/packages/client-runtime/src/state/threadRequests.test.ts +++ b/packages/client-runtime/src/state/threadRequests.test.ts @@ -196,14 +196,20 @@ describe("announcing resolved thread requests", () => { using recorder = record(); const announce = makeThreadRequestsResolvedAnnouncer(environmentId, threadId); - announce([first]); - announce([first, second]); - announce([first, second]); - announce([second]); + const pending = (id: RuntimeRequestId) => ({ id, status: "pending" as const }); + announce([pending(first)]); + announce([pending(first), pending(second)]); + announce([pending(first), pending(second)]); + announce([{ id: first, status: "cancelled" }, pending(second)]); + announce([ + { id: first, status: "cancelled" }, + { id: second, status: "resolved" }, + ]); expect(recorder.events).toEqual([ - { environmentId, threadId, pendingRequestIds: [first] }, - { environmentId, threadId, pendingRequestIds: [second] }, + { environmentId, threadId, pendingRequestIds: [first], unansweredRequestIds: [] }, + { environmentId, threadId, pendingRequestIds: [second], unansweredRequestIds: [first] }, + { environmentId, threadId, pendingRequestIds: [], unansweredRequestIds: [first] }, ]); }); }); diff --git a/packages/client-runtime/src/state/threadRequests.ts b/packages/client-runtime/src/state/threadRequests.ts index 7c3c44c4caa59..99fd12ba5b360 100644 --- a/packages/client-runtime/src/state/threadRequests.ts +++ b/packages/client-runtime/src/state/threadRequests.ts @@ -168,6 +168,8 @@ export interface ThreadRequestsResolved { readonly threadId: ThreadId; /** Every request the thread still waits on; drafts for any other request are stale. */ readonly pendingRequestIds: ReadonlyArray; + /** Requests that closed without an answer (cancelled or expired), so typed drafts were never sent. */ + readonly unansweredRequestIds: ReadonlyArray; } const threadRequestsResolvedListeners = new Set<(event: ThreadRequestsResolved) => void>(); @@ -198,8 +200,8 @@ function notifyThreadRequestsResolved(event: ThreadRequestsResolved): void { } /** - * Tracks one thread's announced pending requests. Feed it the pending ids of - * each authoritative projection; it notifies on the first one and whenever an + * Tracks one thread's announced pending requests. Feed it the runtime requests + * of each authoritative projection; it notifies on the first one and whenever an * announced request is no longer pending. New requests alone stay quiet. */ export function makeThreadRequestsResolvedAnnouncer( @@ -207,22 +209,30 @@ export function makeThreadRequestsResolvedAnnouncer( threadId: ThreadId, ) { let announced: ReadonlySet | null = null; - return (pendingRequestIds: ReadonlyArray) => { + return ( + runtimeRequests: ReadonlyArray< + Pick + >, + ) => { + const pendingRequestIds = runtimeRequests.flatMap((request) => + request.status === "pending" ? [request.id] : [], + ); const pending = new Set(pendingRequestIds); const resolvedSome = announced === null || [...announced].some((id) => !pending.has(id)); announced = pending; - if (resolvedSome) notifyThreadRequestsResolved({ environmentId, threadId, pendingRequestIds }); + if (!resolvedSome) return; + const unansweredRequestIds = runtimeRequests.flatMap((request) => + request.status === "cancelled" || request.status === "expired" ? [request.id] : [], + ); + notifyThreadRequestsResolved({ + environmentId, + threadId, + pendingRequestIds, + unansweredRequestIds, + }); }; } -export function pendingRuntimeRequestIds( - projection: Pick, -): RuntimeRequestId[] { - return projection.runtimeRequests.flatMap((request) => - request.status === "pending" ? [request.id] : [], - ); -} - export function derivePendingThreadRequests( projection: Pick, ): PendingThreadRequests { diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index d3677a6fc72b3..94440ad744e94 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -2500,6 +2500,7 @@ describe("EnvironmentThreads", () => { environmentId: TARGET.environmentId, threadId: THREAD_ID, pendingRequestIds: [], + unansweredRequestIds: [], }); expect(yield* Queue.size(announced)).toBe(0); }), diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 6bbb9f32ee691..a3984290e7b6b 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -53,7 +53,7 @@ import { type ThreadHistoryMeta, } from "./threadHistoryMerge.ts"; import * as ThreadSnapshotLoader from "./threadSnapshotHttp.ts"; -import { makeThreadRequestsResolvedAnnouncer, pendingRuntimeRequestIds } from "./threadRequests.ts"; +import { makeThreadRequestsResolvedAnnouncer } from "./threadRequests.ts"; import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState, @@ -309,7 +309,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const owns = resumeCache === undefined || resumeCache.owner === owner; if (owns && current.status === "deleted") announceRequests([]); else if (owns && current.status === "live" && Option.isSome(current.data)) - announceRequests(pendingRuntimeRequestIds(current.data.value)); + announceRequests(current.data.value.runtimeRequests); committed = { state: current, sequence,