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
9 changes: 1 addition & 8 deletions apps/mobile/src/lib/scopedEntities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EnvironmentId, ProjectId, RuntimeRequestId, ThreadId } from "@t3tools/contracts";
import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";

export function scopedProjectKey(environmentId: EnvironmentId, projectId: ProjectId): string {
return `${environmentId}:${projectId}`;
Expand All @@ -7,10 +7,3 @@ export function scopedProjectKey(environmentId: EnvironmentId, projectId: Projec
export function scopedThreadKey(environmentId: EnvironmentId, threadId: ThreadId): string {
return `${environmentId}:${threadId}`;
}

export function scopedRequestKey(
environmentId: EnvironmentId,
requestId: RuntimeRequestId,
): string {
return `${environmentId}:${requestId}`;
}
1 change: 1 addition & 0 deletions apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ vi.mock("./threads", async () => {
environmentThreadDetails: {
queueWorkflowAtom: () => Atom.make(null),
checkpointSummariesAtom: () => Atom.make([]),
pendingRequestsAtom: () => Atom.make(state.pendingRequests),
},
threadEnvironment: {
respondToApproval: state.approve,
Expand Down
180 changes: 123 additions & 57 deletions apps/mobile/src/state/use-selected-thread-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,27 @@ import {
composerAttachmentsStillUploading,
} from "./composer-attachment-uploads";
import { useAtomValue } from "@effect/atom-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";

import { type ProviderApprovalDecision, type RuntimeRequestId } from "@t3tools/contracts";
import {
type EnvironmentId,
type ProviderApprovalDecision,
type RuntimeRequestId,
type ThreadId,
} from "@t3tools/contracts";
import {
type PendingThreadRequests,
type ThreadUserInputQuestion,
onThreadRequestsResolved,
pruneResolvedUserInputDrafts,
resolvedQuestionDraftKeys,
seedUserInputDraftAnswers,
userInputDraftKey,
} from "@t3tools/client-runtime/state/thread-requests";
import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
import { Atom } from "effect/reactivity";

import { threadEnvironment } from "../state/threads";
import { scopedRequestKey } from "../lib/scopedEntities";
import { environmentThreadDetails, threadEnvironment } from "../state/threads";
import {
buildPendingUserInputAnswers,
setPendingUserInputCustomAnswer,
Expand All @@ -47,6 +55,14 @@ const userInputDraftsByRequestKeyAtom = Atom.make<
Record<string, Record<string, PendingUserInputDraftAnswer>>
>({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts"));

// Writes start from the suggested answer the user was looking at.
function seededDraft(
drafts: Record<string, PendingUserInputDraftAnswer> | undefined,
question: ThreadUserInputQuestion,
): PendingUserInputDraftAnswer | undefined {
return seedUserInputDraftAnswers([question], drafts ?? {})[question.id];
}

function setUserInputDraftOption(
requestKey: string,
question: ThreadUserInputQuestion,
Expand All @@ -59,7 +75,7 @@ function setUserInputDraftOption(
...current[requestKey],
[question.id]: togglePendingUserInputOptionSelection(
question,
current[requestKey]?.[question.id],
seededDraft(current[requestKey], question),
value,
),
},
Expand All @@ -78,13 +94,75 @@ function setUserInputDraftCustomAnswer(
...current[requestKey],
[question.id]: setPendingUserInputCustomAnswer(
question,
current[requestKey]?.[question.id],
seededDraft(current[requestKey], question),
customAnswer,
),
},
});
}

/** Drops a thread's answer and attachment drafts for every request outside `pendingRequestIds`. */
function pruneResolvedRequestDrafts(
environmentId: EnvironmentId,
threadId: ThreadId,
pendingRequestIds: ReadonlyArray<RuntimeRequestId>,
): void {
const drafts = appAtomRegistry.get(userInputDraftsByRequestKeyAtom);
const prunedDrafts = pruneResolvedUserInputDrafts(
drafts,
environmentId,
threadId,
pendingRequestIds,
);
if (prunedDrafts !== drafts) appAtomRegistry.set(userInputDraftsByRequestKeyAtom, prunedDrafts);

const prefix = questionAttachmentDraftPrefix(environmentId, threadId);
const attachmentDrafts = appAtomRegistry.get(questionAttachmentDraftsAtom(prefix));
const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) };
let changed = false;
for (const key of resolvedQuestionDraftKeys(
new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)]),
prefix,
pendingRequestIds,
)) {
if (attachmentDrafts[key]) clearComposerDraft(key);
if (key in counts) {
delete counts[key];
changed = true;
}
}
if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts);
}

// Resolution can come from anywhere: another device, an interrupt, the turn
// ending, or a reload over drafts for requests that resolved meanwhile.
onThreadRequestsResolved(({ environmentId, threadId, pendingRequestIds }) =>
pruneResolvedRequestDrafts(environmentId, threadId, pendingRequestIds),
);

/**
* Drops `answeredRequestId`'s drafts as soon as the server accepts the
* response, before the projection push that resolves it lands.
*/
function collectAnsweredRequestDrafts(
environmentId: EnvironmentId,
threadId: ThreadId,
answeredRequestId: RuntimeRequestId,
) {
const current = appAtomRegistry.get(
environmentThreadDetails.pendingRequestsAtom({ environmentId, threadId }),
);
// Missing detail is not resolution.
if (current === null) return;
pruneResolvedRequestDrafts(
environmentId,
threadId,
[...current.approvals, ...current.userInputs]
.map((request) => request.requestId)
.filter((requestId) => requestId !== answeredRequestId),
);
}

export function useSelectedThreadRequests() {
const respondToApproval = useAtomCommand(
threadEnvironment.respondToApproval,
Expand All @@ -109,19 +187,6 @@ 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(
questionAttachmentDraftsAtom(
Expand All @@ -132,36 +197,20 @@ export function useSelectedThreadRequests() {
);
const preparationCounts = useAtomValue(questionAttachmentPreparationAtom);
const uploadStates = useAtomValue(composerAttachmentUploadsAtom);
useEffect(() => {
if (!selectedThreadShell || !pendingRequests) return;
const prefix = questionAttachmentDraftPrefix(
selectedThreadShell.environmentId,
selectedThreadShell.id,
);
const retained = new Set(
activePendingUserInputs.flatMap((request) =>
request.questions.map((question) =>
questionAttachmentDraftKey(
selectedThreadShell.environmentId,
selectedThreadShell.id,
request.requestId,
question.id,
),
),
),
);
const counts = { ...appAtomRegistry.get(questionAttachmentPreparationAtom) };
let changed = false;
for (const key of new Set([...Object.keys(attachmentDrafts), ...Object.keys(counts)])) {
if (!key.startsWith(prefix) || retained.has(key)) continue;
if (attachmentDrafts[key]) clearComposerDraft(key);
if (key in counts) {
delete counts[key];
changed = true;
}
}
if (changed) appAtomRegistry.set(questionAttachmentPreparationAtom, counts);
}, [activePendingUserInputs, attachmentDrafts, pendingRequests, selectedThreadShell]);
// Suggested answers are shown until the user edits them; only edits are stored.
const activePendingUserInputAnswerDrafts =
activePendingUserInput && selectedThreadShell
? seedUserInputDraftAnswers(
activePendingUserInput.questions,
userInputDraftsByRequestKey[
userInputDraftKey(
selectedThreadShell.environmentId,
selectedThreadShell.id,
activePendingUserInput.requestId,
)
] ?? {},
)
: {};
const activePendingUserInputDrafts =
activePendingUserInput && selectedThreadShell
? Object.fromEntries(
Expand All @@ -182,12 +231,7 @@ export function useSelectedThreadRequests() {
return [
question.id,
{
...userInputDraftsByRequestKey[
scopedRequestKey(
selectedThreadShell.environmentId,
activePendingUserInput.requestId,
)
]?.[question.id],
...activePendingUserInputAnswerDrafts[question.id],
attachmentCount: attachments.length,
attachmentsBlocked:
(attachments.length > 0 &&
Expand All @@ -214,7 +258,11 @@ export function useSelectedThreadRequests() {
return;
}

const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
const requestKey = userInputDraftKey(
selectedThreadShell.environmentId,
selectedThreadShell.id,
requestId,
);
setUserInputDraftOption(requestKey, question, value);
},
[selectedThreadShell],
Expand All @@ -229,7 +277,11 @@ export function useSelectedThreadRequests() {
return;
}

const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId);
const requestKey = userInputDraftKey(
selectedThreadShell.environmentId,
selectedThreadShell.id,
requestId,
);
setUserInputDraftCustomAnswer(requestKey, question, customAnswer);
},
[activePendingUserInputs, selectedThreadShell],
Expand Down Expand Up @@ -339,6 +391,13 @@ export function useSelectedThreadRequests() {
setRespondingUserInputId((current) =>
current === activePendingUserInput.requestId ? null : current,
);
if (result._tag === "Success") {
collectAnsweredRequestDrafts(
selectedThreadShell.environmentId,
selectedThreadShell.id,
activePendingUserInput.requestId,
);
}
return result;
}, [
activePendingUserInput,
Expand Down Expand Up @@ -367,6 +426,13 @@ export function useSelectedThreadRequests() {
setRespondingUserInputId((current) =>
current === activePendingUserInput.requestId ? null : current,
);
if (result._tag === "Success") {
collectAnsweredRequestDrafts(
selectedThreadShell.environmentId,
selectedThreadShell.id,
activePendingUserInput.requestId,
);
}
return result;
}, [activePendingUserInput, dismissUserInput, selectedThreadShell]);

Expand Down
1 change: 0 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1272,7 +1272,6 @@ export default function ChatView(props: ChatViewProps) {
} = usePendingThreadRequests({
environmentId,
activeThreadId,
activeThread,
serverProjection,
supportsQuestionAttachments,
composerDraftTarget,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TurnItemId,
type OrchestrationV2ThreadProjection,
} from "@t3tools/contracts";
import { makeThreadRequestsResolvedAnnouncer } from "@t3tools/client-runtime/state/thread-requests";
import { useLayoutEffect, useRef, type ComponentProps } from "react";
import { expect, it, vi } from "vite-plus/test";

Expand Down Expand Up @@ -88,7 +89,6 @@ function PendingRequestsProbe({
const state = usePendingThreadRequests({
environmentId,
activeThreadId: projection.thread.id,
activeThread: projection.thread,
serverProjection: loading ? null : projection,
supportsQuestionAttachments: false,
composerDraftTarget: { environmentId, threadId: projection.thread.id },
Expand Down Expand Up @@ -161,6 +161,7 @@ it("isolates drafts by environment, thread and request and discards only resolve
await act(() => requests.onAdvanceActivePendingUserInput());
}

// "first" was answered on another device.
await show({
environmentId,
projection: {
Expand All @@ -173,6 +174,12 @@ it("isolates drafts by environment, thread and request and discards only resolve
},
});
expect(requests.activePendingUserInput?.requestId).toBe("second");
await act(() =>
makeThreadRequestsResolvedAnnouncer(
environmentId,
threadRef.threadId,
)([RuntimeRequestId.make("second")]),
);
for (const [index, scope] of scopes.entries()) {
await show(scope);
expect(requests.activePendingQuestionIndex).toBe(index === 0 ? 0 : 1);
Expand Down
Loading
Loading