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.
This commit is contained in:
Amruth Pillai
2026-09-29 18:13:10 +02:00
parent 18597a6de0
commit 59337fcd51
9 changed files with 179 additions and 119 deletions
@@ -31,6 +31,7 @@ import { client, orpc } from "@/libs/orpc/client";
import { Composer, Conversation } from "./conversation";
import { ProviderSetup } from "./provider-setup";
type UsableProvider = ReturnType<typeof useHasUsableAiProvider>["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<string | null>(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) {
</h2>
{usable.length > 0 && (
<DropdownMenu open={modelMenuOpen} onOpenChange={setModelMenuOpen}>
<DropdownMenuTrigger
render={
<button
type="button"
aria-label={t`Model: ${providerLabel}`}
className="flex h-7 max-w-[140px] items-center gap-0.5 rounded-md px-2 text-ink-2 text-xs transition-colors hover:bg-hover"
/>
}
>
<span className="truncate">{provider?.model ?? providerLabel}</span>
<Icon name="expand_more" size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{usable.map((item) => (
<DropdownMenuItem key={item.id} onClick={() => void chooseProvider(item.id)}>
<span className="grid min-w-0 flex-1">
<span className="truncate">{item.label}</span>
<span className="truncate text-ink-3 text-xs">{item.model}</span>
</span>
{item.id === provider?.id && <Icon name="check" size={16} />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void navigate({ to: "/dashboard/settings/ai" })}>
<Trans>Connect another…</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ModelMenu
open={modelMenuOpen}
onOpenChange={setModelMenuOpen}
providers={usable}
current={provider}
label={providerLabel}
onChoose={(id) => void chooseProvider(id)}
/>
)}
<IconButton
@@ -279,6 +258,51 @@ function ConversationLoader({ threadId, document, ...props }: ConversationLoader
);
}
type ModelMenuProps = {
open: boolean;
onOpenChange: (open: boolean) => 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 (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
render={
<button
type="button"
aria-label={t`Model: ${label}`}
className="flex h-7 max-w-[140px] items-center gap-0.5 rounded-md px-2 text-ink-2 text-xs transition-colors hover:bg-hover"
/>
}
>
<span className="truncate">{current?.model ?? label}</span>
<Icon name="expand_more" size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{providers.map((item) => (
<DropdownMenuItem key={item.id} onClick={() => onChoose(item.id)}>
<span className="grid min-w-0 flex-1">
<span className="truncate">{item.label}</span>
<span className="truncate text-ink-3 text-xs">{item.model}</span>
</span>
{item.id === current?.id && <Icon name="check" size={16} />}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => void navigate({ to: "/dashboard/settings/ai" })}>
<Trans>Connect another…</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function Notice({ children }: { children: ReactNode }) {
return <p className="m-4 rounded-xl bg-sunken p-4 text-ink-2 text-sm">{children}</p>;
}
+4 -2
View File
@@ -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(
() => ({
@@ -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) => (
<MessageView
@@ -569,10 +573,9 @@ export function Composer(props: ComposerProps) {
type: "error",
description: getOrpcErrorMessage(error, { fallback: t`Couldn't attach the file.` }),
});
} finally {
setUploading(false);
if (fileInput.current) fileInput.current.value = "";
}
setUploading(false);
if (fileInput.current) fileInput.current.value = "";
};
const chips = [
@@ -605,38 +608,24 @@ export function Composer(props: ComposerProps) {
{(chips.length > 0 || attachments.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{chips.map((chip) => (
<span
<RemovableChip
key={chip.key}
className="flex h-[26px] items-center gap-1 rounded-md bg-sunken ps-2 text-ink-2 text-xs"
>
<Icon name={chip.icon} size={15} />
<span className="max-w-[160px] truncate">{chip.label}</span>
<button
type="button"
aria-label={t`Don't send ${chip.label}`}
onClick={() => onContextChange({ ...context, [chip.key]: false })}
className="grid size-5 place-items-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
<Icon name="close" size={14} />
</button>
</span>
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) => (
<span
<RemovableChip
key={attachment.id}
className="flex h-[26px] items-center gap-1 rounded-md bg-sunken ps-2 text-ink-2 text-xs"
>
<Icon name="attach_file" size={15} />
<span className="max-w-[140px] truncate">{attachment.filename}</span>
<button
type="button"
aria-label={t`Remove ${attachment.filename}`}
onClick={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))}
className="grid size-5 place-items-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
<Icon name="close" size={14} />
</button>
</span>
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))}
/>
))}
</div>
)}
@@ -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 (
<span className="flex h-[26px] items-center gap-1 rounded-md bg-sunken ps-2 text-ink-2 text-xs">
<Icon name={icon} size={15} />
<span className={cn(maxWidth, "truncate")}>{label}</span>
<button
type="button"
aria-label={removeLabel}
onClick={onRemove}
className="grid size-5 place-items-center rounded text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
<Icon name="close" size={14} />
</button>
</span>
);
}
async function copyTranscript(messages: readonly UIMessage[]) {
try {
await navigator.clipboard.writeText(transcriptOf(messages, { user: t`You`, assistant: t`Assistant` }));
+1 -1
View File
@@ -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. */
@@ -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;
@@ -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() },
});
}
@@ -100,6 +100,9 @@ export function ApiKeysSection() {
</p>
) : (
<table className="w-full overflow-hidden rounded-xl text-[13px] starting:opacity-0 outline outline-line transition-opacity duration-standard ease-enter max-sm:block">
<caption className="sr-only">
<Trans>API keys</Trans>
</caption>
<thead className="bg-bg text-ink-3 text-xs max-sm:hidden">
<tr className="h-9 text-start">
<th scope="col" className="ps-3.5 text-start font-medium">
+30 -24
View File
@@ -89,7 +89,12 @@ type TestState = { ok: boolean; label: string; error: string | null } | null;
function useProviderTest() {
const queryClient = useQueryClient();
const [result, setResult] = useState<TestState>(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<ReturnType<typeof test.mutateAsync>> | 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<ReturnType<typeof test.run>> | 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) =>
+1 -1
View File
@@ -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 {