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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 2 additions & 23 deletions apps/web/src/components/chat/usePendingThreadRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,13 @@ import {
} from "@t3tools/contracts";
import {
derivePendingThreadRequests,
onThreadRequestsResolved,
pruneResolvedUserInputDrafts,
seedUserInputDraftAnswers,
userInputDraftKey,
} from "@t3tools/client-runtime/state/thread-requests";
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";
Expand All @@ -39,6 +36,8 @@ import {
} from "../../pendingUserInput";
import {
questionAttachmentDraftId,
usePendingUserInputAnswers,
usePendingUserInputQuestionIndex,
useQuestionAttachmentPreparation,
} from "../../questionAttachments";
import {
Expand All @@ -55,30 +54,10 @@ import { toastManager } from "../ui/toast";
import type { ChatComposerHandle } from "./ChatComposer";

const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
const usePendingUserInputAnswers = create<
Record<string, Record<string, PendingUserInputDraftAnswer>>
>(() => ({}));
const usePendingUserInputQuestionIndex = create<Record<string, number>>(() => ({}));
const NO_PENDING_REQUESTS = { approvals: [], userInputs: [] } as const;
const NO_PENDING_APPROVALS: ReturnType<typeof derivePendingApprovals> = [];
const NO_PENDING_USER_INPUTS: ReturnType<typeof derivePendingUserInputs> = [];

// 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;
Expand Down
57 changes: 55 additions & 2 deletions apps/web/src/questionAttachments.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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({});
},
);
83 changes: 76 additions & 7 deletions apps/web/src/questionAttachments.ts
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, PendingUserInputDraftAnswer>>
>(() => ({}));
export const usePendingUserInputQuestionIndex = create<Record<string, number>>(() => ({}));

export function questionAttachmentDraftPrefix(
environmentId: EnvironmentId,
Expand Down Expand Up @@ -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<RuntimeRequestId>,
): 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);
});
18 changes: 12 additions & 6 deletions packages/client-runtime/src/state/threadRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] },
]);
});
});
Expand Down
34 changes: 22 additions & 12 deletions packages/client-runtime/src/state/threadRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeRequestId>;
/** Requests that closed without an answer (cancelled or expired), so typed drafts were never sent. */
readonly unansweredRequestIds: ReadonlyArray<RuntimeRequestId>;
}

const threadRequestsResolvedListeners = new Set<(event: ThreadRequestsResolved) => void>();
Expand Down Expand Up @@ -198,31 +200,39 @@ 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(
environmentId: EnvironmentId,
threadId: ThreadId,
) {
let announced: ReadonlySet<RuntimeRequestId> | null = null;
return (pendingRequestIds: ReadonlyArray<RuntimeRequestId>) => {
return (
runtimeRequests: ReadonlyArray<
Pick<OrchestrationV2ThreadProjection["runtimeRequests"][number], "id" | "status">
>,
) => {
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<OrchestrationV2ThreadProjection, "runtimeRequests">,
): RuntimeRequestId[] {
return projection.runtimeRequests.flatMap((request) =>
request.status === "pending" ? [request.id] : [],
);
}

export function derivePendingThreadRequests(
projection: Pick<ThreadStructure, "runtimeRequests" | "turnItems">,
): PendingThreadRequests {
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/state/threads-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2500,6 +2500,7 @@ describe("EnvironmentThreads", () => {
environmentId: TARGET.environmentId,
threadId: THREAD_ID,
pendingRequestIds: [],
unansweredRequestIds: [],
});
expect(yield* Queue.size(announced)).toBe(0);
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/client-runtime/src/state/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading