ui improvements
This commit is contained in:
parent
3b86e56c22
commit
91f08b944d
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user