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 {