* initlal commit * Ghostwriter always enabled * rename code * ghostwriter panel * separate component * ui improvements * single thread * copy improvement * dont pop up keyboard shortcuts * markdown renderer * ghostwriter button placement * better UX * ghostwriter copy * meta shortcut * better settings menu * formatting * doocumentation * add tests * race condition * race condition 2 * pass title * more comments * comments * formtting
284 lines
8.2 KiB
TypeScript
284 lines
8.2 KiB
TypeScript
import type { Job, JobChatMessage, JobChatStreamEvent } from "@shared/types";
|
|
import type React from "react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import * as api from "../../api";
|
|
import { Composer } from "./Composer";
|
|
import { MessageList } from "./MessageList";
|
|
|
|
type GhostwriterPanelProps = {
|
|
job: Job;
|
|
};
|
|
|
|
export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|
const [messages, setMessages] = useState<JobChatMessage[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
|
|
|
const messageListRef = useRef<HTMLDivElement | null>(null);
|
|
const streamAbortRef = useRef<AbortController | null>(null);
|
|
const messageCount = messages.length;
|
|
|
|
useEffect(() => {
|
|
if (messageCount === 0) return;
|
|
const container = messageListRef.current;
|
|
if (!container) return;
|
|
const distanceToBottom =
|
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
|
if (distanceToBottom < 120 || isStreaming) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
}, [messageCount, isStreaming]);
|
|
|
|
const loadMessages = useCallback(async () => {
|
|
const data = await api.listJobGhostwriterMessages(job.id, {
|
|
limit: 300,
|
|
});
|
|
setMessages(data.messages);
|
|
}, [job.id]);
|
|
|
|
const load = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
await loadMessages();
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to load Ghostwriter";
|
|
toast.error(message);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [loadMessages]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
return () => {
|
|
streamAbortRef.current?.abort();
|
|
streamAbortRef.current = null;
|
|
};
|
|
}, [load]);
|
|
|
|
const onStreamEvent = useCallback(
|
|
(event: JobChatStreamEvent) => {
|
|
if (event.type === "ready") {
|
|
setActiveRunId(event.runId);
|
|
setStreamingMessageId(event.messageId);
|
|
setMessages((current) => {
|
|
if (current.some((message) => message.id === event.messageId)) {
|
|
return current;
|
|
}
|
|
return [
|
|
...current,
|
|
{
|
|
id: event.messageId,
|
|
threadId: event.threadId,
|
|
jobId: job.id,
|
|
role: "assistant",
|
|
content: "",
|
|
status: "partial",
|
|
tokensIn: null,
|
|
tokensOut: null,
|
|
version: 1,
|
|
replacesMessageId: null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
];
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (event.type === "delta") {
|
|
setMessages((current) =>
|
|
current.map((message) =>
|
|
message.id === event.messageId
|
|
? {
|
|
...message,
|
|
content: `${message.content}${event.delta}`,
|
|
status: "partial",
|
|
updatedAt: new Date().toISOString(),
|
|
}
|
|
: message,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (event.type === "completed" || event.type === "cancelled") {
|
|
setMessages((current) => {
|
|
const next = current.filter(
|
|
(message) => message.id !== event.message.id,
|
|
);
|
|
return [...next, event.message].sort((a, b) =>
|
|
a.createdAt.localeCompare(b.createdAt),
|
|
);
|
|
});
|
|
setStreamingMessageId(null);
|
|
setActiveRunId(null);
|
|
setIsStreaming(false);
|
|
return;
|
|
}
|
|
|
|
if (event.type === "error") {
|
|
toast.error(event.message);
|
|
setStreamingMessageId(null);
|
|
setActiveRunId(null);
|
|
setIsStreaming(false);
|
|
}
|
|
},
|
|
[job.id],
|
|
);
|
|
|
|
const sendMessage = useCallback(
|
|
async (content: string) => {
|
|
if (isStreaming) return;
|
|
|
|
const optimisticUser: JobChatMessage = {
|
|
id: `tmp-user-${Date.now()}`,
|
|
threadId: messages[messages.length - 1]?.threadId || "pending-thread",
|
|
jobId: job.id,
|
|
role: "user",
|
|
content,
|
|
status: "complete",
|
|
tokensIn: null,
|
|
tokensOut: null,
|
|
version: 1,
|
|
replacesMessageId: null,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
|
|
setMessages((current) => [...current, optimisticUser]);
|
|
setIsStreaming(true);
|
|
|
|
const controller = new AbortController();
|
|
streamAbortRef.current = controller;
|
|
|
|
try {
|
|
await api.streamJobGhostwriterMessage(
|
|
job.id,
|
|
{ content, signal: controller.signal },
|
|
{ onEvent: onStreamEvent },
|
|
);
|
|
|
|
await loadMessages();
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
return;
|
|
}
|
|
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to send message";
|
|
toast.error(message);
|
|
} finally {
|
|
streamAbortRef.current = null;
|
|
setIsStreaming(false);
|
|
}
|
|
},
|
|
[isStreaming, job.id, loadMessages, messages, onStreamEvent],
|
|
);
|
|
|
|
const stopStreaming = useCallback(async () => {
|
|
streamAbortRef.current?.abort();
|
|
streamAbortRef.current = null;
|
|
setIsStreaming(false);
|
|
setStreamingMessageId(null);
|
|
const runId = activeRunId;
|
|
setActiveRunId(null);
|
|
|
|
if (!runId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await api.cancelJobGhostwriterRun(job.id, runId);
|
|
await loadMessages();
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : "Failed to stop run";
|
|
toast.error(message);
|
|
}
|
|
}, [activeRunId, job.id, loadMessages]);
|
|
|
|
const canRegenerate = useMemo(() => {
|
|
if (isStreaming || messages.length === 0) return false;
|
|
const last = messages[messages.length - 1];
|
|
return last.role === "assistant";
|
|
}, [isStreaming, messages]);
|
|
|
|
const regenerate = useCallback(async () => {
|
|
if (isStreaming || messages.length === 0) return;
|
|
const last = messages[messages.length - 1];
|
|
if (last.role !== "assistant") return;
|
|
|
|
setIsStreaming(true);
|
|
const controller = new AbortController();
|
|
streamAbortRef.current = controller;
|
|
|
|
try {
|
|
await api.streamRegenerateJobGhostwriterMessage(
|
|
job.id,
|
|
last.id,
|
|
{ signal: controller.signal },
|
|
{ onEvent: onStreamEvent },
|
|
);
|
|
await loadMessages();
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name === "AbortError") {
|
|
return;
|
|
}
|
|
const message =
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to regenerate response";
|
|
toast.error(message);
|
|
} finally {
|
|
streamAbortRef.current = null;
|
|
setIsStreaming(false);
|
|
}
|
|
}, [isStreaming, job.id, loadMessages, messages, onStreamEvent]);
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-1 flex-col">
|
|
<div
|
|
ref={messageListRef}
|
|
className="min-h-0 flex-1 overflow-y-auto border-b border-border/50 pb-3 pr-1"
|
|
>
|
|
{messages.length === 0 && !isLoading ? (
|
|
<div className="flex h-full min-h-[260px] justify-center px-3 flex-col text-left">
|
|
<h4 className="font-medium">
|
|
{job.title} at {job.employer}
|
|
</h4>
|
|
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
|
Ghostwriter already has this job description, your resume and your
|
|
writing style preferences. Ask for tailored response drafts, or
|
|
concise role-fit talking points.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<MessageList
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
streamingMessageId={streamingMessageId}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<Composer
|
|
disabled={isLoading || isStreaming}
|
|
isStreaming={isStreaming}
|
|
canRegenerate={canRegenerate}
|
|
onRegenerate={regenerate}
|
|
onStop={stopStreaming}
|
|
onSend={sendMessage}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|