ui improvements

This commit is contained in:
DaKheera47 2026-02-15 19:06:15 +00:00
parent 3b86e56c22
commit 91f08b944d
3 changed files with 151 additions and 54 deletions

View File

@ -4,11 +4,9 @@ import type {
JobChatStreamEvent,
JobChatThread,
} from "@shared/types";
import { MessageSquare } from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import * as api from "../../api";
import { Composer } from "./Composer";
import { MessageList } from "./MessageList";
@ -23,6 +21,9 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
const [threads, setThreads] = useState<JobChatThread[]>([]);
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [messages, setMessages] = useState<JobChatMessage[]>([]);
const [threadPreviews, setThreadPreviews] = useState<Record<string, string>>(
{},
);
const [isLoading, setIsLoading] = useState(true);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
@ -54,6 +55,12 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
limit: 300,
});
setMessages(data.messages);
const preview = [...data.messages]
.reverse()
.find((message) => !!message.content.trim())?.content;
if (preview) {
setThreadPreviews((current) => ({ ...current, [threadId]: preview }));
}
},
[job.id],
);
@ -63,6 +70,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
title: `${job.title} @ ${job.employer}`,
});
setThreads((current) => [created.thread, ...current]);
setThreadPreviews((current) => ({ ...current, [created.thread.id]: "" }));
setActiveThreadId(created.thread.id);
setMessages([]);
return created.thread;
@ -144,6 +152,13 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
: message,
),
);
const threadId = activeThreadIdRef.current;
if (threadId) {
setThreadPreviews((current) => ({
...current,
[threadId]: `${current[threadId] ?? ""}${event.delta}`.trim(),
}));
}
return;
}
@ -159,6 +174,10 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
setStreamingMessageId(null);
setActiveRunId(null);
setIsStreaming(false);
setThreadPreviews((current) => ({
...current,
[event.message.threadId]: event.message.content,
}));
return;
}
@ -193,6 +212,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
};
setMessages((current) => [...current, optimisticUser]);
setThreadPreviews((current) => ({ ...current, [threadId]: content }));
setIsStreaming(true);
const controller = new AbortController();
@ -287,16 +307,12 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
]);
return (
<Card className="border-border/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquare className="h-4 w-4" />
Ghostwriter
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_minmax(0,1fr)]">
<ThreadList
job={job}
threads={threads}
previews={threadPreviews}
activeThreadId={activeThreadId}
onSelectThread={(threadId) => {
setActiveThreadId(threadId);
@ -308,29 +324,33 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
disabled={isLoading || isStreaming}
/>
<div
ref={messageListRef}
className="max-h-[420px] overflow-y-auto rounded-md border border-border/50 p-3"
>
<MessageList
messages={messages}
isStreaming={isStreaming}
streamingMessageId={streamingMessageId}
/>
<div className="flex min-h-0 flex-1 flex-col border-t border-border/50 pt-4 md:border-t-0 md:border-l md:pl-4 md:pt-0">
<div
ref={messageListRef}
className="min-h-0 flex-1 overflow-y-auto pr-1"
>
<MessageList
messages={messages}
isStreaming={isStreaming}
streamingMessageId={streamingMessageId}
/>
</div>
<div className="mt-4 space-y-3 border-t border-border/50 pt-3">
<RunControls
isStreaming={isStreaming}
canRegenerate={canRegenerate}
onStop={stopStreaming}
onRegenerate={regenerate}
/>
<Composer
disabled={isLoading || isStreaming || !activeThreadId}
onSend={sendMessage}
/>
</div>
</div>
<RunControls
isStreaming={isStreaming}
canRegenerate={canRegenerate}
onStop={stopStreaming}
onRegenerate={regenerate}
/>
<Composer
disabled={isLoading || isStreaming || !activeThreadId}
onSend={sendMessage}
/>
</CardContent>
</Card>
</div>
</div>
);
};

View File

@ -40,7 +40,7 @@ export const MessageList: React.FC<MessageListProps> = ({
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{isUser
? "You"
: `Copilot${message.version > 1 ? ` v${message.version}` : ""}`}
: `Ghostwriter${message.version > 1 ? ` v${message.version}` : ""}`}
</div>
{isActiveStreaming ? (
<StreamingMessage content={message.content} />

View File

@ -1,27 +1,71 @@
import type { JobChatThread } from "@shared/types";
import type { Job, JobChatThread } from "@shared/types";
import type React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ThreadListProps = {
job: Job;
threads: JobChatThread[];
previews: Record<string, string>;
activeThreadId: string | null;
onSelectThread: (threadId: string) => void;
onCreateThread: () => void;
disabled?: boolean;
};
function formatRelativeTime(value: string | null): string {
if (!value) return "Updated just now";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Updated recently";
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
if (absMs < hour) {
const minutes = Math.max(1, Math.round(diffMs / minute));
return `Updated ${rtf.format(minutes, "minute")}`;
}
if (absMs < day) {
const hours = Math.round(diffMs / hour);
return `Updated ${rtf.format(hours, "hour")}`;
}
const days = Math.round(diffMs / day);
return `Updated ${rtf.format(days, "day")}`;
}
function normalizeThreadTitle(input: string | null, fallback: string): string {
const value = input?.trim();
return value && value.length > 0 ? value : fallback;
}
export const ThreadList: React.FC<ThreadListProps> = ({
job,
threads,
previews,
activeThreadId,
onSelectThread,
onCreateThread,
disabled,
}) => {
const titleCounts = new Map<string, number>();
threads.forEach((thread) => {
const normalized = normalizeThreadTitle(
thread.title,
`${job.title} @ ${job.employer}`,
);
titleCounts.set(normalized, (titleCounts.get(normalized) ?? 0) + 1);
});
const seenTitles = new Map<string, number>();
return (
<div className="space-y-2">
<aside className="min-h-0 space-y-3 pr-0 md:pr-4">
<div className="flex items-center justify-between">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Threads
</div>
<Button
@ -29,34 +73,67 @@ export const ThreadList: React.FC<ThreadListProps> = ({
variant="outline"
onClick={onCreateThread}
disabled={disabled}
className="h-8 px-2.5 text-xs"
>
New
</Button>
</div>
<div className="max-h-40 space-y-1 overflow-auto rounded-md border border-border/50 p-1">
<div className="max-h-[13rem] space-y-1 overflow-auto pr-1">
{threads.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground">
No threads yet
</div>
) : (
threads.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => onSelectThread(thread.id)}
className={cn(
"w-full rounded px-2 py-1.5 text-left text-xs transition-colors",
activeThreadId === thread.id
? "bg-primary/10 text-primary"
: "hover:bg-muted",
)}
>
{thread.title || "Untitled thread"}
</button>
))
threads.map((thread) => {
const rawTitle = normalizeThreadTitle(
thread.title,
`${job.title} @ ${job.employer}`,
);
const seenCount = (seenTitles.get(rawTitle) ?? 0) + 1;
seenTitles.set(rawTitle, seenCount);
const hasDuplicates = (titleCounts.get(rawTitle) ?? 0) > 1;
const title = hasDuplicates
? `${rawTitle} (${seenCount})`
: rawTitle;
const preview = previews[thread.id]?.trim() || "No messages yet";
const isActive = activeThreadId === thread.id;
return (
<button
key={thread.id}
type="button"
onClick={() => onSelectThread(thread.id)}
disabled={disabled}
className={cn(
"relative w-full rounded-md border px-3 py-2.5 text-left transition-colors",
isActive
? "border-foreground/25 bg-accent text-accent-foreground"
: "border-transparent hover:border-border/50 hover:bg-muted/40",
)}
>
{isActive && (
<span className="absolute bottom-2 left-0 top-2 w-0.5 rounded-r bg-foreground" />
)}
<div
className={cn(
"truncate pr-2 text-xs",
isActive ? "font-semibold" : "font-medium text-foreground",
)}
>
{title}
</div>
<div className="mt-1 text-[10px] text-muted-foreground">
{formatRelativeTime(thread.lastMessageAt ?? thread.updatedAt)}
</div>
<div className="mt-1 truncate text-[11px] text-muted-foreground/90">
{preview}
</div>
</button>
);
})
)}
</div>
</div>
</aside>
);
};