From 59337fcd51d3334e1c96834d5a3fabffe19d2378 Mon Sep 17 00:00:00 2001
From: Amruth Pillai
Date: Tue, 29 Sep 2026 18:13:10 +0200
Subject: [PATCH] refactor(web): let the assistant, theme and ai settings
compile with the react compiler
Also guards the composer's Enter for Safari IME, gives the undo-detection effect real dependencies, lets the composer grow with its text, names the API keys table and moves acceptResumeProposals next to the other proposal logic.
---
.../features/assistant/assistant-panel.tsx | 96 ++++++++++++-------
apps/web/src/features/assistant/chat.ts | 6 +-
.../src/features/assistant/conversation.tsx | 89 ++++++++++-------
apps/web/src/features/assistant/document.ts | 2 +-
.../resume/editor/proposals/proposal-list.tsx | 20 +---
.../resume/editor/proposals/proposals.ts | 26 ++++-
.../web/src/features/settings/ai/api-keys.tsx | 3 +
.../src/features/settings/ai/providers.tsx | 54 ++++++-----
apps/web/src/features/theme/provider.tsx | 2 +-
9 files changed, 179 insertions(+), 119 deletions(-)
diff --git a/apps/web/src/features/assistant/assistant-panel.tsx b/apps/web/src/features/assistant/assistant-panel.tsx
index ef1d93c89..93e427d55 100644
--- a/apps/web/src/features/assistant/assistant-panel.tsx
+++ b/apps/web/src/features/assistant/assistant-panel.tsx
@@ -31,6 +31,7 @@ import { client, orpc } from "@/libs/orpc/client";
import { Composer, Conversation } from "./conversation";
import { ProviderSetup } from "./provider-setup";
+type UsableProvider = ReturnType["usableProviders"][number];
type ThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
type AssistantPanelProps = {
@@ -59,7 +60,6 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
const [providerId, setProviderId] = useState(null);
const [modelMenuOpen, setModelMenuOpen] = useState(false);
const [starting, setStarting] = useState(false);
- const navigate = useNavigate();
const mine = (threads.data ?? []).filter((thread) => belongsTo(thread, document));
// Opens the document's latest conversation unless a new one (or another) was asked for.
@@ -75,11 +75,12 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
const start = async (text: string) => {
if (starting) return;
setStarting(true);
+ const input = {
+ ...(document.kind === "letter" ? { coverLetterId: document.id } : { resumeId: document.id }),
+ ...(provider ? { aiProviderId: provider.id } : {}),
+ };
try {
- const created = await client.agent.threads.start({
- ...(document.kind === "letter" ? { coverLetterId: document.id } : { resumeId: document.id }),
- ...(provider ? { aiProviderId: provider.id } : {}),
- });
+ const created = await client.agent.threads.start(input);
setPrompt(text);
setSelected(created.id);
useEditorStore.getState().setAssistantSuggestions(null);
@@ -90,9 +91,8 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't start the conversation. Try again.` }),
});
- } finally {
- setStarting(false);
}
+ setStarting(false);
};
// A question from ⌘K (or Prepare for next step) starts a conversation as soon as the assistant can.
@@ -125,35 +125,14 @@ export function AssistantPanel({ document, onClose }: AssistantPanelProps) {
{usable.length > 0 && (
-
-
- }
- >
- {provider?.model ?? providerLabel}
-
-
-
- {usable.map((item) => (
- void chooseProvider(item.id)}>
-
- {item.label}
- {item.model}
-
- {item.id === provider?.id && }
-
- ))}
-
- void navigate({ to: "/dashboard/settings/ai" })}>
- Connect another…
-
-
-
+ void chooseProvider(id)}
+ />
)}
void;
+ providers: readonly UsableProvider[];
+ current: UsableProvider | undefined;
+ label: string;
+ onChoose: (id: string) => void;
+};
+
+function ModelMenu({ open, onOpenChange, providers, current, label, onChoose }: ModelMenuProps) {
+ const navigate = useNavigate();
+
+ return (
+
+
+ }
+ >
+ {current?.model ?? label}
+
+
+
+ {providers.map((item) => (
+ onChoose(item.id)}>
+
+ {item.label}
+ {item.model}
+
+ {item.id === current?.id && }
+
+ ))}
+
+ void navigate({ to: "/dashboard/settings/ai" })}>
+ Connect another…
+
+
+
+ );
+}
+
function Notice({ children }: { children: ReactNode }) {
return {children}
;
}
diff --git a/apps/web/src/features/assistant/chat.ts b/apps/web/src/features/assistant/chat.ts
index ba00cf4ab..52d0a1c7c 100644
--- a/apps/web/src/features/assistant/chat.ts
+++ b/apps/web/src/features/assistant/chat.ts
@@ -3,7 +3,7 @@ import type { FileUIPart, UIMessage } from "ai";
import { useChat } from "@ai-sdk/react";
import { eventIteratorToUnproxiedDataStream } from "@orpc/client";
import { lastAssistantMessageIsCompleteWithToolCalls, parseJsonEventStream, uiMessageChunkSchema } from "ai";
-import { useMemo, useRef } from "react";
+import { useLayoutEffect, useMemo, useRef } from "react";
import { agentMessageMetadataSchema } from "@reactive-resume/ai/tools/agent-tool-contracts";
import { streamClient } from "@/libs/orpc/client";
@@ -80,7 +80,9 @@ type UseAssistantChatInput = {
*/
export function useAssistantChat({ threadId, initialMessages, resume, context, onFinish }: UseAssistantChatInput) {
const contextRef = useRef(context);
- contextRef.current = context;
+ useLayoutEffect(() => {
+ contextRef.current = context;
+ });
const transport = useMemo(
() => ({
diff --git a/apps/web/src/features/assistant/conversation.tsx b/apps/web/src/features/assistant/conversation.tsx
index 76cbd15d9..61476181d 100644
--- a/apps/web/src/features/assistant/conversation.tsx
+++ b/apps/web/src/features/assistant/conversation.tsx
@@ -1,5 +1,6 @@
import type { ProposeEditsOutput } from "@reactive-resume/ai/tools/agent-tool-contracts";
import type { Proposal } from "@reactive-resume/resume/proposals";
+import type { IconName } from "@reactive-resume/ui/components/icon";
import type { UIMessage } from "ai";
import type { ReactNode } from "react";
import type { ChatAttachment, MessageContext } from "./chat";
@@ -8,7 +9,7 @@ import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useQueryClient } from "@tanstack/react-query";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
-import { useEffect, useId, useMemo, useRef, useState } from "react";
+import { useEffect, useEffectEvent, useId, useMemo, useRef, useState } from "react";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
import { IconButton } from "@reactive-resume/ui/components/icon-button";
@@ -18,6 +19,7 @@ import { cn } from "@reactive-resume/utils/style";
import { ChangeSet } from "@/features/resume/editor/proposals/proposal-list";
import { useEditorStore } from "@/features/resume/editor/store";
import { getOrpcErrorMessage } from "@/libs/error-message";
+import { isImeComposing } from "@/libs/keyboard";
import { ENTER_CLASS, POP_CLASS } from "@/libs/motion";
import { client, orpc } from "@/libs/orpc/client";
import { attachmentPart, fileToBase64, transcriptOf, useAssistantChat } from "./chat";
@@ -121,6 +123,8 @@ export function Conversation(props: ConversationProps) {
.catch(() => undefined);
};
+ const recordUndone = useEffectEvent(record);
+
// Undoing an accepted edit makes it pending again.
useEffect(() => {
for (const message of messages) {
@@ -129,10 +133,10 @@ export function Conversation(props: ConversationProps) {
const undone = toProposals(part, statuses, document).filter(
(proposal) => proposal.status === "accepted" && document.stateOf(proposal) === "pending",
);
- if (undone.length > 0) record(message, part, undone, "pending");
+ if (undone.length > 0) recordUndone(message, part, undone, "pending");
}
}
- });
+ }, [messages, statuses, document]);
// Follows the reply while it streams, unless the user scrolled up to read.
useEffect(() => {
@@ -154,7 +158,7 @@ export function Conversation(props: ConversationProps) {
role="log"
aria-live="polite"
aria-label={t`Conversation`}
- className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4"
+ className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4"
>
{messages.map((message, index) => (
0 || attachments.length > 0) && (
{chips.map((chip) => (
-
-
- {chip.label}
-
-
+ icon={chip.icon}
+ label={chip.label}
+ maxWidth="max-w-[160px]"
+ removeLabel={t`Don't send ${chip.label}`}
+ onRemove={() => onContextChange({ ...context, [chip.key]: false })}
+ />
))}
{attachments.map((attachment) => (
-
-
- {attachment.filename}
-
-
+ icon="attach_file"
+ label={attachment.filename}
+ maxWidth="max-w-[140px]"
+ removeLabel={t`Remove ${attachment.filename}`}
+ onRemove={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))}
+ />
))}
)}
@@ -653,7 +642,7 @@ export function Composer(props: ComposerProps) {
placeholder={t`Ask, or describe a change…`}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
- if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
+ if (event.key === "Enter" && !event.shiftKey && !isImeComposing(event)) {
event.preventDefault();
submit();
}
@@ -662,7 +651,7 @@ export function Composer(props: ComposerProps) {
props.onStop();
}
}}
- className="max-h-40 min-h-[44px] flex-1 resize-none bg-transparent px-1.5 py-1 text-sm outline-none placeholder:text-ink-3"
+ className="field-sizing-content max-h-40 min-h-[44px] flex-1 resize-none bg-transparent px-1.5 py-1 text-sm outline-none placeholder:text-ink-3"
/>
{props.threadId && (
<>
@@ -702,6 +691,32 @@ export function Composer(props: ComposerProps) {
);
}
+type RemovableChipProps = {
+ icon: IconName;
+ label: string;
+ /** The label's max-width class (Tailwind needs the whole class name in the source). */
+ maxWidth: string;
+ removeLabel: string;
+ onRemove: () => void;
+};
+
+function RemovableChip({ icon, label, maxWidth, removeLabel, onRemove }: RemovableChipProps) {
+ return (
+
+
+ {label}
+
+
+ );
+}
+
async function copyTranscript(messages: readonly UIMessage[]) {
try {
await navigator.clipboard.writeText(transcriptOf(messages, { user: t`You`, assistant: t`Assistant` }));
diff --git a/apps/web/src/features/assistant/document.ts b/apps/web/src/features/assistant/document.ts
index 0aa632e6c..d7afbacfc 100644
--- a/apps/web/src/features/assistant/document.ts
+++ b/apps/web/src/features/assistant/document.ts
@@ -14,7 +14,7 @@ import { applicationsListQueryOptions } from "@/features/applications/queries";
import { useLetterEditorStore } from "@/features/letters/store";
import { useCurrentResume } from "@/features/resume/builder/draft";
import { getSectionName } from "@/features/resume/editor/check/issues";
-import { acceptResumeProposals } from "@/features/resume/editor/proposals/proposal-list";
+import { acceptResumeProposals } from "@/features/resume/editor/proposals/proposals";
import { describeEntry } from "@/features/resume/editor/write/model";
/** The document the assistant works on, as the panel needs it. Resumes and letters each provide one. */
diff --git a/apps/web/src/features/resume/editor/proposals/proposal-list.tsx b/apps/web/src/features/resume/editor/proposals/proposal-list.tsx
index 681fc40a8..196ac5cdf 100644
--- a/apps/web/src/features/resume/editor/proposals/proposal-list.tsx
+++ b/apps/web/src/features/resume/editor/proposals/proposal-list.tsx
@@ -4,33 +4,19 @@ import type { KeyboardEvent, ReactNode } from "react";
import { t } from "@lingui/core/macro";
import { Plural, Trans } from "@lingui/react/macro";
import { useId, useState } from "react";
-import { applyProposal, getProposalState } from "@reactive-resume/resume/proposals";
+import { getProposalState } from "@reactive-resume/resume/proposals";
import { Button } from "@reactive-resume/ui/components/button";
import { Icon } from "@reactive-resume/ui/components/icon";
-import { toast } from "@reactive-resume/ui/components/toast";
import { cn } from "@reactive-resume/utils/style";
-import { useIsResumeLocked, useResumeStore } from "@/features/resume/builder/draft";
+import { useIsResumeLocked } from "@/features/resume/builder/draft";
import { ENTER_CLASS, POP_CLASS } from "@/libs/motion";
import { useEditorStore } from "../store";
+import { acceptResumeProposals } from "./proposals";
/** The visible text of a passage's HTML, for the card. */
const passageText = (html: string) =>
new DOMParser().parseFromString(html, "text/html").body.textContent?.replaceAll("\u00a0", " ").trim() ?? "";
-/** Applies proposals to the resume as one undo step; the toast's Undo takes them back, and they show as pending again. */
-export function acceptResumeProposals(proposals: readonly Proposal[]) {
- useResumeStore.getState().updateResumeData(
- (draft) => {
- for (const proposal of proposals) applyProposal(draft, proposal);
- },
- { newStep: true },
- );
- toast.add({
- description: proposals.length === 1 ? t`Edit applied` : t`${proposals.length} edits applied`,
- actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() },
- });
-}
-
type ProposalListProps = {
proposals: readonly Proposal[];
data: ResumeData;
diff --git a/apps/web/src/features/resume/editor/proposals/proposals.ts b/apps/web/src/features/resume/editor/proposals/proposals.ts
index b6471baa3..91aca49bb 100644
--- a/apps/web/src/features/resume/editor/proposals/proposals.ts
+++ b/apps/web/src/features/resume/editor/proposals/proposals.ts
@@ -1,7 +1,17 @@
import type { Proposal } from "@reactive-resume/resume/proposals";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
+import { t } from "@lingui/core/macro";
import { produce } from "immer";
-import { getProposalState, readTarget, splitBlock, splitBlocks, writeTarget } from "@reactive-resume/resume/proposals";
+import {
+ applyProposal,
+ getProposalState,
+ readTarget,
+ splitBlock,
+ splitBlocks,
+ writeTarget,
+} from "@reactive-resume/resume/proposals";
+import { toast } from "@reactive-resume/ui/components/toast";
+import { useResumeStore } from "@/features/resume/builder/draft";
// Colours of the page marks (README §5.9), as the PDF needs them: old text struck through in grey, new text on
// a pale accent highlight.
@@ -59,3 +69,17 @@ export function markProposals(data: ResumeData, proposals: readonly Proposal[]):
}
});
}
+
+/** Applies proposals to the resume as one undo step; the toast's Undo takes them back, and they show as pending again. */
+export function acceptResumeProposals(proposals: readonly Proposal[]) {
+ useResumeStore.getState().updateResumeData(
+ (draft) => {
+ for (const proposal of proposals) applyProposal(draft, proposal);
+ },
+ { newStep: true },
+ );
+ toast.add({
+ description: proposals.length === 1 ? t`Edit applied` : t`${proposals.length} edits applied`,
+ actionProps: { children: t`Undo`, onClick: () => useResumeStore.getState().undo() },
+ });
+}
diff --git a/apps/web/src/features/settings/ai/api-keys.tsx b/apps/web/src/features/settings/ai/api-keys.tsx
index 93ea9309b..66571d259 100644
--- a/apps/web/src/features/settings/ai/api-keys.tsx
+++ b/apps/web/src/features/settings/ai/api-keys.tsx
@@ -100,6 +100,9 @@ export function ApiKeysSection() {
) : (
+
+ API keys
+
|
diff --git a/apps/web/src/features/settings/ai/providers.tsx b/apps/web/src/features/settings/ai/providers.tsx
index a8e4485b8..9deaadfcd 100644
--- a/apps/web/src/features/settings/ai/providers.tsx
+++ b/apps/web/src/features/settings/ai/providers.tsx
@@ -89,7 +89,12 @@ type TestState = { ok: boolean; label: string; error: string | null } | null;
function useProviderTest() {
const queryClient = useQueryClient();
const [result, setResult] = useState(null);
- const test = useMutation(orpc.aiProviders.test.mutationOptions({ meta: { noInvalidate: true } }));
+ const test = useMutation(
+ orpc.aiProviders.test.mutationOptions({
+ meta: { noInvalidate: true },
+ onSettled: () => queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() }),
+ }),
+ );
const run = async (id: string) => {
setResult(null);
@@ -107,8 +112,6 @@ function useProviderTest() {
};
setResult(next);
return next;
- } finally {
- void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
}
};
@@ -265,21 +268,17 @@ function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
const save = async () => {
setFailure(null);
+ const input = {
+ label: fields.label.trim() || providerLabel(provider),
+ provider,
+ model: fields.model.trim(),
+ baseURL: fields.baseURL.trim(),
+ apiKey: fields.apiKey.trim(),
+ };
+ let tested: Awaited> | undefined;
try {
- const created = await create.mutateAsync({
- label: fields.label.trim() || providerLabel(provider),
- provider,
- model: fields.model.trim(),
- baseURL: fields.baseURL.trim(),
- apiKey: fields.apiKey.trim(),
- });
- const tested = await test.mutateAsync({ id: created.id });
- if (tested.testStatus === "success") {
- onOpenChange(false);
- } else {
- // The provider is saved either way; its row keeps the error and a Test button.
- setFailure(tested.testError ?? t`The provider didn't answer. Check the key, the model and the base URL.`);
- }
+ const created = await create.mutateAsync(input);
+ tested = await test.mutateAsync({ id: created.id });
} catch (error) {
setFailure(
getOrpcErrorMessage(error, {
@@ -290,8 +289,14 @@ function AddProviderDialog({ open, onOpenChange }: AddProviderDialogProps) {
fallback: t`Couldn't save the provider.`,
}),
);
- } finally {
- void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
+ }
+ void queryClient.invalidateQueries({ queryKey: orpc.aiProviders.list.key() });
+ if (!tested) return;
+ if (tested.testStatus === "success") {
+ onOpenChange(false);
+ } else {
+ // The provider is saved either way; its row keeps the error and a Test button.
+ setFailure(tested.testError ?? t`The provider didn't answer. Check the key, the model and the base URL.`);
}
};
@@ -401,17 +406,18 @@ function EditProviderForm({ provider, onClose }: EditProviderFormProps) {
const save = async () => {
setFailure(null);
+ let result: Awaited> | undefined;
try {
await update.mutateAsync({ id: provider.id, ...changes });
// A new model, key or address needs a fresh test before the app uses it.
- const result = await test.run(provider.id);
- if (result.ok) onClose();
- else setFailure(result.error ?? t`The provider didn't answer.`);
+ result = await test.run(provider.id);
} catch (error) {
setFailure(getOrpcErrorMessage(error, { fallback: t`Couldn't save the provider.` }));
- } finally {
- void invalidate();
}
+ void invalidate();
+ if (!result) return;
+ if (result.ok) onClose();
+ else setFailure(result.error ?? t`The provider didn't answer.`);
};
const setEnabled = (enabled: boolean) =>
diff --git a/apps/web/src/features/theme/provider.tsx b/apps/web/src/features/theme/provider.tsx
index 8d1896f73..d0dc4ca74 100644
--- a/apps/web/src/features/theme/provider.tsx
+++ b/apps/web/src/features/theme/provider.tsx
@@ -59,8 +59,8 @@ export function ThemeProvider({ children, theme }: Props) {
if (!playSound) return;
+ const soundClip = next === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
try {
- const soundClip = next === "dark" ? "/sounds/switch-off.mp3" : "/sounds/switch-on.mp3";
const audio = new Audio(soundClip);
await audio.play();
} catch {
|