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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Upstream: t3code 0.0.31

### Avi Code

- Answers rejected by an expired question now return after restarting Avi Code (#123)
- A stuck provider question can always be dismissed without restarting Avi Code (#122)
- Restarting Avi Code keeps your provider choices while provider discovery finishes (#122)
- Files in unregistered sibling repositories now open from the repository that owns them (#121)
Expand Down
3 changes: 3 additions & 0 deletions FUTURE_ENHANCEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ ActivityWatch is authoritative for human time; sessions and GitHub only enrich a

## Deferred

- Expired questionnaire answers are restored as plain composer text so the user can confirm and
resend them. Reconstructing the original multi-step questionnaire would require a durable client
draft schema and is unnecessary while the plain-text recovery preserves every submitted value.
- `/btw` silently discards attached images, terminal contexts, and preview annotations. The
`/plan`/`/default` branch in `ChatView`'s send handler refuses to claim the input when any of
those are present, so they survive; the `/btw` branch has no such guard and clears the composer
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Prioritized work. Structure: **shipped foundation → alpha verification → nex
worktree icon.
- [x] Opt-in opening of finished chats at the top of their last response instead of the live edge.
- [x] Stuck provider questions remain dismissible and provider choices survive desktop restart.
- [x] Durable recovery of answers submitted to questions whose provider session already ended.

## Personal alpha verification

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2814,13 +2814,37 @@ describe("ProviderCommandReactor", () => {
expect(expiredActivity?.payload).toMatchObject({
requestId: "user-input-request-1",
expired: true,
answers: { sandbox_mode: "workspace-write" },
});

// Nothing failed: the session outlived the question, which is not the
// user's doing and must not read as an error.
expect(
thread?.activities.some((activity) => activity.kind === "provider.user-input.respond.failed"),
).toBe(false);

await harness.runEffect(
harness.engine.dispatch({
type: "thread.user-input.respond",
commandId: CommandId.make("cmd-user-input-respond-stale-duplicate"),
threadId: ThreadId.make("thread-1"),
requestId: asApprovalRequestId("user-input-request-1"),
answers: { sandbox_mode: "workspace-write" },
createdAt: now,
}),
);
await harness.drain();
const afterDuplicate = await readThread(harness);
expect(
afterDuplicate?.activities.filter(
(activity) =>
activity.kind === "user-input.resolved" &&
typeof activity.payload === "object" &&
activity.payload !== null &&
(activity.payload as Record<string, unknown>).requestId === "user-input-request-1",
),
).toHaveLength(1);
expect(harness.respondToUserInput).toHaveBeenCalledTimes(1);
});

it("closes a user-input request as expired when no provider session is bound", async () => {
Expand Down Expand Up @@ -2873,6 +2897,9 @@ describe("ProviderCommandReactor", () => {

const thread = await readThread(harness);
expect(findUserInputResolved(thread, "user-input-request-2")?.summary).toBe("Question expired");
expect(findUserInputResolved(thread, "user-input-request-2")?.payload).toMatchObject({
answers: { sandbox_mode: "workspace-write" },
});
expect(harness.respondToUserInput).not.toHaveBeenCalled();
expect(
thread?.activities.some((activity) => activity.kind === "provider.user-input.respond.failed"),
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ const make = Effect.gen(function* () {
readonly threadId: ThreadId;
readonly requestId: string;
readonly createdAt: string;
readonly answers?: Readonly<Record<string, unknown>>;
}) =>
Effect.all({
commandId: serverCommandId("user-input-expired-activity"),
Expand All @@ -381,7 +382,7 @@ const make = Effect.gen(function* () {
summary: USER_INPUT_EXPIRED_SUMMARY,
payload: {
requestId: input.requestId,
answers: {},
answers: input.answers ?? {},
expired: true,
detail: USER_INPUT_EXPIRED_DETAIL,
},
Expand Down Expand Up @@ -1380,6 +1381,21 @@ const make = Effect.gen(function* () {
if (!thread) {
return;
}
// Avi Code addition: response commands are durable and may be replayed
// after a restart or submitted repeatedly while the first is settling.
// Once the request is closed, later copies have nothing left to do.
if (
thread.activities.some((activity) => {
if (activity.kind !== "user-input.resolved") return false;
const payload =
typeof activity.payload === "object" && activity.payload !== null
? (activity.payload as Record<string, unknown>)
: null;
return payload?.requestId === event.payload.requestId;
})
) {
return;
}
const hasSession = thread.session && thread.session.status !== "stopped";
// Avi Code addition: no session means the question outlived the thing
// that asked it. That is an expiry, not a failure the user caused.
Expand All @@ -1388,6 +1404,7 @@ const make = Effect.gen(function* () {
threadId: event.payload.threadId,
requestId: event.payload.requestId,
createdAt: event.payload.createdAt,
answers: event.payload.answers,
});
}

Expand All @@ -1407,6 +1424,7 @@ const make = Effect.gen(function* () {
threadId: event.payload.threadId,
requestId: event.payload.requestId,
createdAt: event.payload.createdAt,
answers: event.payload.answers,
})
: appendProviderFailureActivity({
threadId: event.payload.threadId,
Expand Down
102 changes: 83 additions & 19 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,11 @@ import {
import {
buildPendingUserInputAnswers,
derivePendingUserInputProgress,
formatExpiredUserInputAnswers,
formatExpiredUserInputDraft,
hasHandledExpiredUserInputRecovery,
markExpiredUserInputRecoveryHandled,
mergeExpiredUserInputWithComposerDraft,
omitPendingUserInputRequestIds,
setPendingUserInputCustomAnswer,
togglePendingUserInputOptionSelection,
Expand Down Expand Up @@ -175,6 +179,7 @@ import {
AlarmClockIcon,
ChevronDownIcon,
ClockIcon,
MessageSquareReplyIcon,
GitBranchIcon,
TriangleAlertIcon,
SquarePenIcon,
Expand Down Expand Up @@ -2257,19 +2262,54 @@ function ChatViewContent(props: ChatViewProps) {
[threadActivities],
);
const recoveredExpiredUserInputIdsRef = useRef<Set<string>>(new Set());
const [deferredExpiredUserInputRecovery, setDeferredExpiredUserInputRecovery] = useState<{
requestId: string;
prompt: string;
} | null>(null);
const restoreExpiredUserInput = useCallback(
(recovery: { requestId: string; prompt: string }) => {
const nextPrompt = mergeExpiredUserInputWithComposerDraft(promptRef.current, recovery.prompt);
promptRef.current = nextPrompt;
setComposerDraftPrompt(composerDraftTarget, nextPrompt);
composerRef.current?.resetCursorState({
cursor: collapseExpandedComposerCursor(nextPrompt, nextPrompt.length),
prompt: nextPrompt,
detectTrigger: true,
});
markExpiredUserInputRecoveryHandled(window.localStorage, recovery.requestId);
setDeferredExpiredUserInputRecovery(null);
},
[composerDraftTarget, composerRef, setComposerDraftPrompt],
);
useEffect(() => {
const unseen = expiredUserInputs.filter(
(entry) => !recoveredExpiredUserInputIdsRef.current.has(entry.requestId),
(entry) =>
!recoveredExpiredUserInputIdsRef.current.has(entry.requestId) &&
!hasHandledExpiredUserInputRecovery(window.localStorage, entry.requestId),
);
if (unseen.length === 0) return;

let recoveredPrompt: string | null = null;
let recoveredRequestId: string | null = null;
for (const entry of unseen) {
recoveredExpiredUserInputIdsRef.current.add(entry.requestId);
if (recoveredPrompt !== null) continue;
const draft = pendingUserInputAnswersByRequestId[entry.requestId];
if (!draft) continue;
recoveredPrompt = formatExpiredUserInputDraft(entry.questions, draft);
recoveredPrompt = entry.submittedAnswers
? formatExpiredUserInputAnswers(entry.questions, entry.submittedAnswers)
: null;
if (recoveredPrompt === null) {
const draft = pendingUserInputAnswersByRequestId[entry.requestId];
if (draft) {
recoveredPrompt = formatExpiredUserInputDraft(entry.questions, draft);
}
}
if (recoveredPrompt !== null) {
recoveredRequestId = entry.requestId;
setDeferredExpiredUserInputRecovery({
requestId: entry.requestId,
prompt: recoveredPrompt,
});
}
}

const expiredRequestIds = new Set(unseen.map((entry) => entry.requestId));
Expand All @@ -2280,22 +2320,13 @@ function ChatViewContent(props: ChatViewProps) {
omitPendingUserInputRequestIds(existing, expiredRequestIds),
);

// Never clobber something the user is already typing.
// Restore immediately when safe. Otherwise the banner below offers an
// explicit restore that appends without overwriting the current draft.
if (recoveredPrompt === null || promptRef.current.trim().length > 0) return;
promptRef.current = recoveredPrompt;
setComposerDraftPrompt(composerDraftTarget, recoveredPrompt);
composerRef.current?.resetCursorState({
cursor: collapseExpandedComposerCursor(recoveredPrompt, recoveredPrompt.length),
prompt: recoveredPrompt,
detectTrigger: true,
});
}, [
composerDraftTarget,
composerRef,
expiredUserInputs,
pendingUserInputAnswersByRequestId,
setComposerDraftPrompt,
]);
if (recoveredRequestId !== null) {
restoreExpiredUserInput({ requestId: recoveredRequestId, prompt: recoveredPrompt });
}
}, [expiredUserInputs, pendingUserInputAnswersByRequestId, restoreExpiredUserInput]);
const activeProposedPlan = useMemo(() => {
if (!latestTurnSettled) {
return null;
Expand Down Expand Up @@ -4637,6 +4668,35 @@ function ChatViewContent(props: ChatViewProps) {
);
const composerBannerItems = useMemo<ComposerBannerStackItem[]>(() => {
const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
const expiredAnswerItems: ComposerBannerStackItem[] = deferredExpiredUserInputRecovery
? [
{
id: `expired-user-input:${deferredExpiredUserInputRecovery.requestId}`,
variant: "info",
icon: <MessageSquareReplyIcon />,
title: "Your answer is safe",
description:
"The provider session ended before it received your answer. Restore it as a new message to continue.",
actions: (
<Button
size="xs"
variant="outline"
onClick={() => restoreExpiredUserInput(deferredExpiredUserInputRecovery)}
>
Restore answer
</Button>
),
dismissLabel: "Dismiss recovered answer",
onDismiss: () => {
markExpiredUserInputRecoveryHandled(
window.localStorage,
deferredExpiredUserInputRecovery.requestId,
);
setDeferredExpiredUserInputRecovery(null);
},
},
]
: [];
// Avi Code addition: a send held until the running turn finishes. Says the
// reload limitation out loud, because nothing persists the hold.
const heldSendItems: ComposerBannerStackItem[] = isHoldingSend
Expand Down Expand Up @@ -4720,13 +4780,15 @@ function ChatViewContent(props: ChatViewProps) {
return [
...heldSendItems,
...forkEditItems,
...expiredAnswerItems,
...systemComposerBannerItems,
...parkedThreadItems,
];
}
return [
...heldSendItems,
...forkEditItems,
...expiredAnswerItems,
...systemComposerBannerItems,
{
id: `branch-mismatch:${activeBranchMismatchKey}`,
Expand Down Expand Up @@ -4774,6 +4836,7 @@ function ChatViewContent(props: ChatViewProps) {
activeThreadKey,
cancelForkEdit,
cancelHeldSend,
deferredExpiredUserInputRecovery,
forkEditState,
isForkingThread,
isHoldingSend,
Expand All @@ -4783,6 +4846,7 @@ function ChatViewContent(props: ChatViewProps) {
isRestoringThreadBranch,
localCheckoutBranchMismatch,
parkedThreadBannerItem,
restoreExpiredUserInput,
showBranchMismatchBanner,
systemComposerBannerItems,
]);
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/pendingUserInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
countAnsweredPendingUserInputQuestions,
derivePendingUserInputProgress,
findFirstUnansweredPendingUserInputQuestionIndex,
formatExpiredUserInputAnswers,
formatExpiredUserInputDraft,
hasHandledExpiredUserInputRecovery,
markExpiredUserInputRecoveryHandled,
mergeExpiredUserInputWithComposerDraft,
omitPendingUserInputRequestIds,
resolvePendingUserInputAnswer,
setPendingUserInputCustomAnswer,
Expand Down Expand Up @@ -335,6 +339,47 @@ describe("formatExpiredUserInputDraft", () => {
});
});

describe("formatExpiredUserInputAnswers", () => {
it("restores persisted custom and multiple-choice answers", () => {
expect(
formatExpiredUserInputAnswers([singleSelectQuestion, multiSelectQuestion], {
scope: "A long custom answer",
areas: ["Server", "Web"],
}),
).toBe("Scope: A long custom answer\nAreas: Server, Web");
});

it("returns null for an old expiry without persisted answers", () => {
expect(formatExpiredUserInputAnswers([singleSelectQuestion], {})).toBe(null);
});
});

describe("mergeExpiredUserInputWithComposerDraft", () => {
it("does not overwrite text already in the composer", () => {
expect(mergeExpiredUserInputWithComposerDraft("Current draft", "Recovered answer")).toBe(
"Current draft\n\nRecovered answer",
);
});

it("uses the recovered answer directly for an empty composer", () => {
expect(mergeExpiredUserInputWithComposerDraft("", "Recovered answer")).toBe("Recovered answer");
});
});

describe("expired user-input recovery receipt", () => {
it("remembers a restored request across renderer reloads", () => {
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
};
expect(hasHandledExpiredUserInputRecovery(storage, "req-1")).toBe(false);
markExpiredUserInputRecoveryHandled(storage, "req-1");
expect(hasHandledExpiredUserInputRecovery(storage, "req-1")).toBe(true);
expect(hasHandledExpiredUserInputRecovery(storage, "req-2")).toBe(false);
});
});

describe("omitPendingUserInputRequestIds", () => {
it("returns the same object when nothing matches, so state setters do not re-render", () => {
const entries = { "req-1": 0 };
Expand Down
Loading
Loading