mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 15:22:20 +10:00
chore: lint using react-doctor, update translations, dynamic imports
This commit is contained in:
@@ -30,7 +30,7 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import { motion } from "motion/react";
|
||||
import { m } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
@@ -176,10 +176,13 @@ function fileToBase64(file: File): Promise<string> {
|
||||
}
|
||||
|
||||
function textFromMessage(message: UIMessage) {
|
||||
return message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
const textParts: string[] = [];
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text") textParts.push(part.text);
|
||||
}
|
||||
|
||||
return textParts.join("\n");
|
||||
}
|
||||
|
||||
function parseAgentSseStream(stream: ReadableStream<string>) {
|
||||
@@ -218,7 +221,7 @@ function parseAgentSseStream(stream: ReadableStream<string>) {
|
||||
|
||||
function promptPreview(prompt: string) {
|
||||
const words = prompt.split(/\s+/).filter(Boolean);
|
||||
return `${words.slice(0, 7).join(" ")}${words.length > 7 ? "..." : ""}`;
|
||||
return `${words.slice(0, 7).join(" ")}${words.length > 7 ? "…" : ""}`;
|
||||
}
|
||||
|
||||
function chunkPrompts(prompts: string[], columns: number) {
|
||||
@@ -252,20 +255,24 @@ function StarterPromptMarquee({ onSelect }: { onSelect: (prompt: string) => void
|
||||
return (
|
||||
<div className="relative mx-auto grid w-full max-w-4xl gap-3 overflow-hidden py-1 [mask-image:linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]">
|
||||
{promptRows.map((row, rowIndex) => {
|
||||
const marqueePrompts = [...row, ...row, ...row];
|
||||
const marqueePrompts = row.flatMap((prompt) => [
|
||||
{ id: `${prompt}-primary`, prompt },
|
||||
{ id: `${prompt}-repeat-a`, prompt },
|
||||
{ id: `${prompt}-repeat-b`, prompt },
|
||||
]);
|
||||
const duration = 135 + rowIndex * 22;
|
||||
const animate = rowIndex % 2 === 0 ? { x: ["0%", "-33.333%"] } : { x: ["-33.333%", "0%"] };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`prompt-row-${rowIndex}`}
|
||||
<m.div
|
||||
key={`prompt-row-${row.join("|")}`}
|
||||
className="flex w-max gap-3"
|
||||
animate={animate}
|
||||
transition={{ duration, ease: "linear", repeat: Number.POSITIVE_INFINITY }}
|
||||
>
|
||||
{marqueePrompts.map((prompt, index) => (
|
||||
{marqueePrompts.map(({ id, prompt }) => (
|
||||
<Button
|
||||
key={`${prompt}-${index}`}
|
||||
key={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 rounded-full bg-background/70 px-3 font-normal text-muted-foreground hover:text-foreground"
|
||||
@@ -274,13 +281,21 @@ function StarterPromptMarquee({ onSelect }: { onSelect: (prompt: string) => void
|
||||
{promptPreview(prompt)}
|
||||
</Button>
|
||||
))}
|
||||
</motion.div>
|
||||
</m.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getMessagePartKey(messageId: string, part: UIMessage["parts"][number]) {
|
||||
if ("toolCallId" in part && typeof part.toolCallId === "string")
|
||||
return `${messageId}-${part.type}-${part.toolCallId}`;
|
||||
if (part.type === "text") return `${messageId}-text-${part.text}`;
|
||||
if (part.type === "file") return `${messageId}-file-${part.url ?? part.filename}`;
|
||||
return `${messageId}-${part.type}-${JSON.stringify(part)}`;
|
||||
}
|
||||
|
||||
function AssistantMarkdown({ text }: { text: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
@@ -436,9 +451,9 @@ function ChatMessage({
|
||||
: "w-full max-w-full py-1 text-foreground",
|
||||
)}
|
||||
>
|
||||
{message.parts.map((part, index) => (
|
||||
{message.parts.map((part) => (
|
||||
<MessagePart
|
||||
key={`${message.id}-${index}`}
|
||||
key={getMessagePartKey(message.id, part)}
|
||||
part={part}
|
||||
isUser={isUser}
|
||||
onAnswer={onAnswer}
|
||||
@@ -621,18 +636,19 @@ function AgentChat({
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const attachment = await client.agent.attachments.create({
|
||||
threadId,
|
||||
filename: file.name,
|
||||
mediaType: file.type || "application/octet-stream",
|
||||
data: await fileToBase64(file),
|
||||
});
|
||||
setPendingAttachments((current) => [
|
||||
...current,
|
||||
{ id: attachment.id, filename: attachment.filename, mediaType: attachment.mediaType },
|
||||
]);
|
||||
}
|
||||
const attachments = await Promise.all(
|
||||
Array.from(files).map(async (file) => {
|
||||
const attachment = await client.agent.attachments.create({
|
||||
threadId,
|
||||
filename: file.name,
|
||||
mediaType: file.type || "application/octet-stream",
|
||||
data: await fileToBase64(file),
|
||||
});
|
||||
return { id: attachment.id, filename: attachment.filename, mediaType: attachment.mediaType };
|
||||
}),
|
||||
);
|
||||
|
||||
setPendingAttachments((current) => [...current, ...attachments]);
|
||||
toast.success(t`Attachment uploaded.`);
|
||||
} catch (error) {
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Failed to upload attachment.` }));
|
||||
@@ -669,245 +685,358 @@ function AgentChat({
|
||||
toast.success(t`Conversation JSON copied.`);
|
||||
};
|
||||
|
||||
const copyConversationText = () => {
|
||||
void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n"));
|
||||
toast.success(t`Conversation copied.`);
|
||||
};
|
||||
|
||||
const answerToolCall = (toolCallId: string, answer: string) => {
|
||||
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
|
||||
};
|
||||
|
||||
const revertAction = (actionId: string) => {
|
||||
const confirmation = window.confirm(
|
||||
t`Restore the resume to before this patch? This will roll back this patch and any patches applied after it.`,
|
||||
);
|
||||
if (!confirmation) return;
|
||||
|
||||
revertMutation.mutate(
|
||||
{ id: actionId },
|
||||
{
|
||||
onSuccess: (action) => {
|
||||
if (action.status === "conflicted") {
|
||||
toast.error(action.revertMessage ?? t`Cannot restore; the resume has changed since this edit was applied.`);
|
||||
} else if (action.status === "rolled_back" || action.status === "reverted") {
|
||||
toast.success(t`Patch rolled back.`);
|
||||
}
|
||||
void refreshThread();
|
||||
},
|
||||
onError: (error) => toast.error(getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` })),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const retryLastMessage = () => {
|
||||
clearError();
|
||||
void regenerate();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{onToggleThreads ? (
|
||||
<Button size="icon-sm" variant="ghost" onClick={onToggleThreads}>
|
||||
<SidebarSimpleIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Toggle threads</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<AgentChatHeader
|
||||
isArchived={isArchived}
|
||||
isArchivePending={archiveMutation.isPending}
|
||||
isDeletePending={deleteMutation.isPending}
|
||||
onArchive={handleArchive}
|
||||
onCopyConversation={copyConversationText}
|
||||
onCopyConversationJson={copyConversationJson}
|
||||
onDelete={() => void handleDelete()}
|
||||
onToggleResume={onToggleResume}
|
||||
onToggleThreads={onToggleThreads}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 truncate font-semibold">
|
||||
<Trans>Chat</Trans>
|
||||
<AgentChatReadOnlyBanner isReadOnly={isReadOnly} readOnlyReason={readOnlyReason} />
|
||||
|
||||
<AgentChatMessages
|
||||
actionsById={actionsById}
|
||||
error={error}
|
||||
isReadOnly={isReadOnly}
|
||||
isReverting={revertMutation.isPending}
|
||||
isStreaming={isStreaming}
|
||||
messages={messages}
|
||||
onAnswer={answerToolCall}
|
||||
onRevert={revertAction}
|
||||
onRetry={retryLastMessage}
|
||||
onStarterSelect={setInput}
|
||||
/>
|
||||
|
||||
<AgentChatComposer
|
||||
fileInputRef={fileInputRef}
|
||||
input={input}
|
||||
isReadOnly={isReadOnly}
|
||||
isStreaming={isStreaming}
|
||||
isUploading={isUploading}
|
||||
pendingAttachments={pendingAttachments}
|
||||
onInputChange={setInput}
|
||||
onSend={send}
|
||||
onStopRun={() => void stopRun()}
|
||||
onUploadFiles={(files) => void uploadFiles(files)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentChatReadOnlyBanner({
|
||||
isReadOnly,
|
||||
readOnlyReason,
|
||||
}: {
|
||||
isReadOnly: boolean;
|
||||
readOnlyReason: "archived" | "missing" | null;
|
||||
}) {
|
||||
if (!isReadOnly) return null;
|
||||
|
||||
return (
|
||||
<div className="border-amber-300 border-b bg-amber-50 px-4 py-2 text-amber-950 text-sm dark:bg-amber-950/20 dark:text-amber-200">
|
||||
{readOnlyReason === "archived" ? (
|
||||
<Trans>This thread is archived. New messages cannot be sent.</Trans>
|
||||
) : (
|
||||
<Trans>This thread is read-only because the working resume or AI provider is unavailable.</Trans>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentChatMessages({
|
||||
actionsById,
|
||||
error,
|
||||
isReadOnly,
|
||||
isReverting,
|
||||
isStreaming,
|
||||
messages,
|
||||
onAnswer,
|
||||
onRevert,
|
||||
onRetry,
|
||||
onStarterSelect,
|
||||
}: {
|
||||
actionsById: Map<string, AgentAction>;
|
||||
error: Error | undefined;
|
||||
isReadOnly: boolean;
|
||||
isReverting: boolean;
|
||||
isStreaming: boolean;
|
||||
messages: UIMessage[];
|
||||
onAnswer: (toolCallId: string, answer: string) => void;
|
||||
onRevert: (actionId: string) => void;
|
||||
onRetry: () => void;
|
||||
onStarterSelect: (prompt: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-4 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="grid gap-6 py-12 text-center">
|
||||
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-2xl">
|
||||
<Trans>What do you want to do?</Trans>
|
||||
</h2>
|
||||
<StarterPromptMarquee onSelect={onStarterSelect} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onToggleResume ? (
|
||||
<Button size="icon-sm" variant="ghost" onClick={onToggleResume}>
|
||||
<SquaresFourIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Toggle resume preview</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon-sm" variant="ghost">
|
||||
<DotsThreeVerticalIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Thread actions</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(messages.map(textFromMessage).join("\n\n"));
|
||||
toast.success(t`Conversation copied.`);
|
||||
}}
|
||||
>
|
||||
<CopyIcon />
|
||||
<Trans>Copy</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyConversationJson}>
|
||||
<CopyIcon />
|
||||
<Trans>Copy JSON</Trans>
|
||||
</DropdownMenuItem>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isReverting={isReverting}
|
||||
actionsById={actionsById}
|
||||
onAnswer={onAnswer}
|
||||
onRevert={onRevert}
|
||||
/>
|
||||
))}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{isStreaming ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-md bg-muted px-4 py-3 text-muted-foreground text-sm">
|
||||
<Trans>Working…</Trans>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isArchived ? (
|
||||
<DropdownMenuItem disabled={archiveMutation.isPending} onClick={handleArchive}>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archive</Trans>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-rose-300 bg-rose-50 p-3 text-rose-950 text-sm dark:bg-rose-950/20 dark:text-rose-200">
|
||||
<span>{error.message}</span>
|
||||
{!isReadOnly ? (
|
||||
<Button size="sm" variant="outline" type="button" onClick={onRetry}>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => void handleDelete()}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
function AgentChatHeader({
|
||||
isArchived,
|
||||
isArchivePending,
|
||||
isDeletePending,
|
||||
onArchive,
|
||||
onCopyConversation,
|
||||
onCopyConversationJson,
|
||||
onDelete,
|
||||
onToggleResume,
|
||||
onToggleThreads,
|
||||
}: {
|
||||
isArchived: boolean;
|
||||
isArchivePending: boolean;
|
||||
isDeletePending: boolean;
|
||||
onArchive: () => void;
|
||||
onCopyConversation: () => void;
|
||||
onCopyConversationJson: () => void;
|
||||
onDelete: () => void;
|
||||
onToggleResume?: () => void;
|
||||
onToggleThreads?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{onToggleThreads ? (
|
||||
<Button size="icon-sm" variant="ghost" onClick={onToggleThreads}>
|
||||
<SidebarSimpleIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Toggle threads</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="min-w-0 truncate font-semibold">
|
||||
<Trans>Chat</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onToggleResume ? (
|
||||
<Button size="icon-sm" variant="ghost" onClick={onToggleResume}>
|
||||
<SquaresFourIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Toggle resume preview</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button size="icon-sm" variant="ghost">
|
||||
<DotsThreeVerticalIcon />
|
||||
<span className="sr-only">
|
||||
<Trans>Thread actions</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="border-amber-300 border-b bg-amber-50 px-4 py-2 text-amber-950 text-sm dark:bg-amber-950/20 dark:text-amber-200">
|
||||
{readOnlyReason === "archived" ? (
|
||||
<Trans>This thread is archived. New messages cannot be sent.</Trans>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onCopyConversation}>
|
||||
<CopyIcon />
|
||||
<Trans>Copy</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onCopyConversationJson}>
|
||||
<CopyIcon />
|
||||
<Trans>Copy JSON</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{!isArchived ? (
|
||||
<DropdownMenuItem disabled={isArchivePending} onClick={onArchive}>
|
||||
<ArchiveIcon />
|
||||
<Trans>Archive</Trans>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuItem variant="destructive" disabled={isDeletePending} onClick={onDelete}>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentChatComposer({
|
||||
fileInputRef,
|
||||
input,
|
||||
isReadOnly,
|
||||
isStreaming,
|
||||
isUploading,
|
||||
pendingAttachments,
|
||||
onInputChange,
|
||||
onSend,
|
||||
onStopRun,
|
||||
onUploadFiles,
|
||||
}: {
|
||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
input: string;
|
||||
isReadOnly: boolean;
|
||||
isStreaming: boolean;
|
||||
isUploading: boolean;
|
||||
pendingAttachments: Array<Pick<AgentAttachment, "filename" | "id" | "mediaType">>;
|
||||
onInputChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onStopRun: () => void;
|
||||
onUploadFiles: (files: FileList | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<form
|
||||
className="border-t p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSend();
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-2">
|
||||
{pendingAttachments.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pendingAttachments.map((attachment) => (
|
||||
<Badge key={attachment.id} variant="secondary">
|
||||
<FileIcon />
|
||||
{attachment.filename}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-1 rounded-md border bg-card p-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => onUploadFiles(event.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t`Attach files`}
|
||||
disabled={isReadOnly || isUploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isUploading ? <ArrowClockwiseIcon className="animate-spin" /> : <PaperclipIcon />}
|
||||
</Button>
|
||||
<Textarea
|
||||
value={input}
|
||||
rows={1}
|
||||
disabled={isReadOnly || isStreaming}
|
||||
onChange={(event) => onInputChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
onSend();
|
||||
}}
|
||||
placeholder={isReadOnly ? t`This thread is read-only` : t`Ask anything about this resume`}
|
||||
className="max-h-40 min-h-9 resize-none border-0 bg-transparent p-2 leading-5 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
{isStreaming && !isReadOnly ? (
|
||||
<Button type="button" size="icon" variant="outline" aria-label={t`Stop generation`} onClick={onStopRun}>
|
||||
<StopIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Trans>This thread is read-only because the working resume or AI provider is unavailable.</Trans>
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
aria-label={t`Send message`}
|
||||
disabled={isReadOnly || isUploading || (!input.trim() && pendingAttachments.length === 0)}
|
||||
>
|
||||
<PaperPlaneRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-4 p-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="grid gap-6 py-12 text-center">
|
||||
<SparkleIcon className="mx-auto size-8 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-2xl">
|
||||
<Trans>What do you want to do?</Trans>
|
||||
</h2>
|
||||
<StarterPromptMarquee onSelect={setInput} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{messages.map((message) => (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isReverting={revertMutation.isPending}
|
||||
actionsById={actionsById}
|
||||
onAnswer={(toolCallId, answer) => {
|
||||
addToolOutput({ tool: "ask_user_question", toolCallId, output: answer });
|
||||
}}
|
||||
onRevert={(actionId) => {
|
||||
const confirmation = window.confirm(
|
||||
t`Restore the resume to before this patch? This will roll back this patch and any patches applied after it.`,
|
||||
);
|
||||
if (!confirmation) return;
|
||||
|
||||
revertMutation.mutate(
|
||||
{ id: actionId },
|
||||
{
|
||||
onSuccess: (action) => {
|
||||
if (action.status === "conflicted") {
|
||||
toast.error(
|
||||
action.revertMessage ??
|
||||
t`Cannot restore; the resume has changed since this edit was applied.`,
|
||||
);
|
||||
} else if (action.status === "rolled_back" || action.status === "reverted") {
|
||||
toast.success(t`Patch rolled back.`);
|
||||
}
|
||||
void refreshThread();
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(getOrpcErrorMessage(error, { fallback: t`Could not restore this patch.` })),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isStreaming ? (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-md bg-muted px-4 py-3 text-muted-foreground text-sm">
|
||||
<Trans>Working...</Trans>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md border border-rose-300 bg-rose-50 p-3 text-rose-950 text-sm dark:bg-rose-950/20 dark:text-rose-200">
|
||||
<span>{error.message}</span>
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearError();
|
||||
void regenerate();
|
||||
}}
|
||||
>
|
||||
<ArrowClockwiseIcon />
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<form
|
||||
className="border-t p-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-2">
|
||||
{pendingAttachments.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{pendingAttachments.map((attachment) => (
|
||||
<Badge key={attachment.id} variant="secondary">
|
||||
<FileIcon />
|
||||
{attachment.filename}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-end gap-1 rounded-md border bg-card p-1.5">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadFiles(event.target.files)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t`Attach files`}
|
||||
disabled={isReadOnly || isUploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isUploading ? <ArrowClockwiseIcon className="animate-spin" /> : <PaperclipIcon />}
|
||||
</Button>
|
||||
<Textarea
|
||||
value={input}
|
||||
rows={1}
|
||||
disabled={isReadOnly || isStreaming}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
send();
|
||||
}}
|
||||
placeholder={isReadOnly ? t`This thread is read-only` : t`Ask anything about this resume`}
|
||||
className="max-h-40 min-h-9 resize-none border-0 bg-transparent px-2 py-2 leading-5 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
{isStreaming && !isReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label={t`Stop generation`}
|
||||
onClick={() => void stopRun()}
|
||||
>
|
||||
<StopIcon />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
aria-label={t`Send message`}
|
||||
disabled={isReadOnly || isUploading || (!input.trim() && pendingAttachments.length === 0)}
|
||||
>
|
||||
<PaperPlaneRightIcon />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -974,7 +1103,7 @@ function ResumePane({ resume }: { resume: AgentThreadDetail["resume"] }) {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated…`);
|
||||
|
||||
setIsPrinting(true);
|
||||
|
||||
@@ -1117,7 +1246,7 @@ function RouteComponent() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid h-svh place-items-center bg-background text-muted-foreground">
|
||||
<Trans>Loading agent workspace...</Trans>
|
||||
<Trans>Loading agent workspace…</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, ChatCircleDotsIcon, FilePlusIcon, GearSixIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
@@ -44,17 +44,10 @@ export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
|
||||
() => providers?.filter((provider) => provider.enabled && provider.testStatus === "success") ?? [],
|
||||
[providers],
|
||||
);
|
||||
const [aiProviderId, setAiProviderId] = useState<string | null>(null);
|
||||
const [sourceResumeId, setSourceResumeId] = useState<string | null>(resumeId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
if (aiProviderId || usableProviders.length === 0) return;
|
||||
setAiProviderId(usableProviders[0]?.id ?? null);
|
||||
}, [aiProviderId, usableProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
setSourceResumeId(resumeId ?? null);
|
||||
}, [resumeId]);
|
||||
const [aiProviderIdOverride, setAiProviderIdOverride] = useState<string | null | undefined>(undefined);
|
||||
const [sourceResumeIdOverride, setSourceResumeIdOverride] = useState<string | null | undefined>(undefined);
|
||||
const aiProviderId = aiProviderIdOverride ?? usableProviders[0]?.id ?? null;
|
||||
const sourceResumeId = sourceResumeIdOverride ?? resumeId ?? null;
|
||||
|
||||
const providerOptions = usableProviders.map((provider) => ({
|
||||
value: provider.id,
|
||||
@@ -119,8 +112,8 @@ export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
|
||||
value={aiProviderId}
|
||||
options={providerOptions}
|
||||
disabled={isLoadingProviders || providerOptions.length === 0}
|
||||
placeholder={isLoadingProviders ? t`Loading providers...` : t`Select a tested provider`}
|
||||
onValueChange={setAiProviderId}
|
||||
placeholder={isLoadingProviders ? t`Loading providers…` : t`Select a tested provider`}
|
||||
onValueChange={setAiProviderIdOverride}
|
||||
/>
|
||||
{providerOptions.length === 0 && !isLoadingProviders ? (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
|
||||
@@ -157,8 +150,8 @@ export function NewThreadSetup({ resumeId }: { resumeId?: string }) {
|
||||
showClear={false}
|
||||
options={resumeOptions}
|
||||
disabled={isLoadingResumes}
|
||||
placeholder={isLoadingResumes ? t`Loading resumes...` : t`Choose a resume`}
|
||||
onValueChange={(value) => setSourceResumeId(value && value !== "__scratch__" ? value : null)}
|
||||
placeholder={isLoadingResumes ? t`Loading resumes…` : t`Choose a resume`}
|
||||
onValueChange={(value) => setSourceResumeIdOverride(value && value !== "__scratch__" ? value : null)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm">
|
||||
<Badge variant="secondary" className="h-7 gap-1.5 rounded-md px-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
@@ -28,28 +29,26 @@ import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type AgentThreadSummary = RouterOutput["agent"]["threads"]["list"][number];
|
||||
|
||||
function formatRelativeTime(value: Date | string, locale: string) {
|
||||
const RELATIVE_TIME_DIVISIONS: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
|
||||
{ amount: 31_536_000_000, unit: "year" },
|
||||
{ amount: 2_592_000_000, unit: "month" },
|
||||
{ amount: 604_800_000, unit: "week" },
|
||||
{ amount: 86_400_000, unit: "day" },
|
||||
{ amount: 3_600_000, unit: "hour" },
|
||||
{ amount: 60_000, unit: "minute" },
|
||||
];
|
||||
|
||||
function formatRelativeTime(value: Date | string, formatter: Intl.RelativeTimeFormat) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
const absMs = Math.abs(diffMs);
|
||||
const divisions: Array<{ amount: number; unit: Intl.RelativeTimeFormatUnit }> = [
|
||||
{ amount: 31_536_000_000, unit: "year" },
|
||||
{ amount: 2_592_000_000, unit: "month" },
|
||||
{ amount: 604_800_000, unit: "week" },
|
||||
{ amount: 86_400_000, unit: "day" },
|
||||
{ amount: 3_600_000, unit: "hour" },
|
||||
{ amount: 60_000, unit: "minute" },
|
||||
];
|
||||
|
||||
if (absMs < 60_000) return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(0, "second");
|
||||
if (absMs < 60_000) return formatter.format(0, "second");
|
||||
|
||||
const division = divisions.find((candidate) => absMs >= candidate.amount);
|
||||
const division = RELATIVE_TIME_DIVISIONS.find((candidate) => absMs >= candidate.amount);
|
||||
if (!division) return "";
|
||||
|
||||
return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(
|
||||
Math.round(diffMs / division.amount),
|
||||
division.unit,
|
||||
);
|
||||
return formatter.format(Math.round(diffMs / division.amount), division.unit);
|
||||
}
|
||||
|
||||
function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
|
||||
@@ -130,6 +129,10 @@ function ThreadActions({ thread, activeThreadId }: { thread: AgentThreadSummary;
|
||||
|
||||
function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; activeThreadId: string | null }) {
|
||||
const { i18n } = useLingui();
|
||||
const relativeTimeFormatter = useMemo(
|
||||
() => Reflect.construct(Intl.RelativeTimeFormat, [i18n.locale, { numeric: "auto" }]) as Intl.RelativeTimeFormat,
|
||||
[i18n.locale],
|
||||
);
|
||||
const isActive = thread.id === activeThreadId;
|
||||
const isArchived = thread.status === "archived";
|
||||
const title = thread.title === thread.resumeName ? t`New thread` : thread.title;
|
||||
@@ -149,7 +152,7 @@ function ThreadRow({ thread, activeThreadId }: { thread: AgentThreadSummary; act
|
||||
>
|
||||
<div className="truncate font-medium">{title}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{formatRelativeTime(thread.lastMessageAt, i18n.locale)}
|
||||
{formatRelativeTime(thread.lastMessageAt, relativeTimeFormatter)}
|
||||
</div>
|
||||
</Link>
|
||||
<ThreadActions thread={thread} activeThreadId={activeThreadId} />
|
||||
@@ -197,7 +200,7 @@ export function AgentThreadSidebar({
|
||||
|
||||
{isLoading ? (
|
||||
<div className="px-3 py-2 text-muted-foreground text-sm">
|
||||
<Trans>Loading threads...</Trans>
|
||||
<Trans>Loading threads…</Trans>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user