ui improvements
This commit is contained in:
parent
3b86e56c22
commit
91f08b944d
@ -4,11 +4,9 @@ import type {
|
|||||||
JobChatStreamEvent,
|
JobChatStreamEvent,
|
||||||
JobChatThread,
|
JobChatThread,
|
||||||
} from "@shared/types";
|
} from "@shared/types";
|
||||||
import { MessageSquare } from "lucide-react";
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import * as api from "../../api";
|
import * as api from "../../api";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
@ -23,6 +21,9 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
const [threads, setThreads] = useState<JobChatThread[]>([]);
|
const [threads, setThreads] = useState<JobChatThread[]>([]);
|
||||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||||
const [messages, setMessages] = useState<JobChatMessage[]>([]);
|
const [messages, setMessages] = useState<JobChatMessage[]>([]);
|
||||||
|
const [threadPreviews, setThreadPreviews] = useState<Record<string, string>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
|
const [streamingMessageId, setStreamingMessageId] = useState<string | null>(
|
||||||
@ -54,6 +55,12 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
limit: 300,
|
limit: 300,
|
||||||
});
|
});
|
||||||
setMessages(data.messages);
|
setMessages(data.messages);
|
||||||
|
const preview = [...data.messages]
|
||||||
|
.reverse()
|
||||||
|
.find((message) => !!message.content.trim())?.content;
|
||||||
|
if (preview) {
|
||||||
|
setThreadPreviews((current) => ({ ...current, [threadId]: preview }));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[job.id],
|
[job.id],
|
||||||
);
|
);
|
||||||
@ -63,6 +70,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
title: `${job.title} @ ${job.employer}`,
|
title: `${job.title} @ ${job.employer}`,
|
||||||
});
|
});
|
||||||
setThreads((current) => [created.thread, ...current]);
|
setThreads((current) => [created.thread, ...current]);
|
||||||
|
setThreadPreviews((current) => ({ ...current, [created.thread.id]: "" }));
|
||||||
setActiveThreadId(created.thread.id);
|
setActiveThreadId(created.thread.id);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
return created.thread;
|
return created.thread;
|
||||||
@ -144,6 +152,13 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
: message,
|
: message,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const threadId = activeThreadIdRef.current;
|
||||||
|
if (threadId) {
|
||||||
|
setThreadPreviews((current) => ({
|
||||||
|
...current,
|
||||||
|
[threadId]: `${current[threadId] ?? ""}${event.delta}`.trim(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,6 +174,10 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
setStreamingMessageId(null);
|
setStreamingMessageId(null);
|
||||||
setActiveRunId(null);
|
setActiveRunId(null);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
|
setThreadPreviews((current) => ({
|
||||||
|
...current,
|
||||||
|
[event.message.threadId]: event.message.content,
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,6 +212,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setMessages((current) => [...current, optimisticUser]);
|
setMessages((current) => [...current, optimisticUser]);
|
||||||
|
setThreadPreviews((current) => ({ ...current, [threadId]: content }));
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -287,16 +307,12 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/50">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<CardHeader>
|
<div className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_minmax(0,1fr)]">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
|
||||||
<MessageSquare className="h-4 w-4" />
|
|
||||||
Ghostwriter
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<ThreadList
|
<ThreadList
|
||||||
|
job={job}
|
||||||
threads={threads}
|
threads={threads}
|
||||||
|
previews={threadPreviews}
|
||||||
activeThreadId={activeThreadId}
|
activeThreadId={activeThreadId}
|
||||||
onSelectThread={(threadId) => {
|
onSelectThread={(threadId) => {
|
||||||
setActiveThreadId(threadId);
|
setActiveThreadId(threadId);
|
||||||
@ -308,9 +324,10 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
disabled={isLoading || isStreaming}
|
disabled={isLoading || isStreaming}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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
|
<div
|
||||||
ref={messageListRef}
|
ref={messageListRef}
|
||||||
className="max-h-[420px] overflow-y-auto rounded-md border border-border/50 p-3"
|
className="min-h-0 flex-1 overflow-y-auto pr-1"
|
||||||
>
|
>
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={messages}
|
||||||
@ -319,6 +336,7 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3 border-t border-border/50 pt-3">
|
||||||
<RunControls
|
<RunControls
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
canRegenerate={canRegenerate}
|
canRegenerate={canRegenerate}
|
||||||
@ -330,7 +348,9 @@ export const GhostwriterPanel: React.FC<GhostwriterPanelProps> = ({ job }) => {
|
|||||||
disabled={isLoading || isStreaming || !activeThreadId}
|
disabled={isLoading || isStreaming || !activeThreadId}
|
||||||
onSend={sendMessage}
|
onSend={sendMessage}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -40,7 +40,7 @@ export const MessageList: React.FC<MessageListProps> = ({
|
|||||||
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
<div className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
{isUser
|
{isUser
|
||||||
? "You"
|
? "You"
|
||||||
: `Copilot${message.version > 1 ? ` v${message.version}` : ""}`}
|
: `Ghostwriter${message.version > 1 ? ` v${message.version}` : ""}`}
|
||||||
</div>
|
</div>
|
||||||
{isActiveStreaming ? (
|
{isActiveStreaming ? (
|
||||||
<StreamingMessage content={message.content} />
|
<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 type React from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type ThreadListProps = {
|
type ThreadListProps = {
|
||||||
|
job: Job;
|
||||||
threads: JobChatThread[];
|
threads: JobChatThread[];
|
||||||
|
previews: Record<string, string>;
|
||||||
activeThreadId: string | null;
|
activeThreadId: string | null;
|
||||||
onSelectThread: (threadId: string) => void;
|
onSelectThread: (threadId: string) => void;
|
||||||
onCreateThread: () => void;
|
onCreateThread: () => void;
|
||||||
disabled?: boolean;
|
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> = ({
|
export const ThreadList: React.FC<ThreadListProps> = ({
|
||||||
|
job,
|
||||||
threads,
|
threads,
|
||||||
|
previews,
|
||||||
activeThreadId,
|
activeThreadId,
|
||||||
onSelectThread,
|
onSelectThread,
|
||||||
onCreateThread,
|
onCreateThread,
|
||||||
disabled,
|
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 (
|
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="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
|
Threads
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@ -29,34 +73,67 @@ export const ThreadList: React.FC<ThreadListProps> = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onCreateThread}
|
onClick={onCreateThread}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
className="h-8 px-2.5 text-xs"
|
||||||
>
|
>
|
||||||
New
|
New
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{threads.length === 0 ? (
|
||||||
<div className="p-2 text-xs text-muted-foreground">
|
<div className="p-2 text-xs text-muted-foreground">
|
||||||
No threads yet
|
No threads yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
threads.map((thread) => (
|
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
|
<button
|
||||||
key={thread.id}
|
key={thread.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelectThread(thread.id)}
|
onClick={() => onSelectThread(thread.id)}
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full rounded px-2 py-1.5 text-left text-xs transition-colors",
|
"relative w-full rounded-md border px-3 py-2.5 text-left transition-colors",
|
||||||
activeThreadId === thread.id
|
isActive
|
||||||
? "bg-primary/10 text-primary"
|
? "border-foreground/25 bg-accent text-accent-foreground"
|
||||||
: "hover:bg-muted",
|
: "border-transparent hover:border-border/50 hover:bg-muted/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{thread.title || "Untitled thread"}
|
{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>
|
</button>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</aside>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user