All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
577 lines
23 KiB
TypeScript
577 lines
23 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { api } from '@/lib/api';
|
|
import type { Conversation, Project } from '@/lib/types';
|
|
|
|
export interface ConversationSummary {
|
|
id: string;
|
|
title: string | null;
|
|
projectId: string | null;
|
|
updatedAt: string;
|
|
archived?: boolean;
|
|
}
|
|
|
|
export interface ConversationSidebarRef {
|
|
refresh: () => void;
|
|
addConversation: (conversation: ConversationSummary) => void;
|
|
}
|
|
|
|
interface ConversationSidebarProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
currentConversationId: string | null;
|
|
onSelectConversation: (conversationId: string | null) => void;
|
|
onNewConversation: (projectId?: string | null) => void;
|
|
}
|
|
|
|
interface GroupedConversations {
|
|
key: string;
|
|
label: string;
|
|
projectId: string | null;
|
|
conversations: ConversationSummary[];
|
|
}
|
|
|
|
function toSummary(conversation: Conversation): ConversationSummary {
|
|
return {
|
|
id: conversation.id,
|
|
title: conversation.title,
|
|
projectId: conversation.projectId,
|
|
updatedAt: conversation.updatedAt,
|
|
archived: conversation.archived,
|
|
};
|
|
}
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffMinutes = Math.floor(diffMs / 60_000);
|
|
const diffHours = Math.floor(diffMs / 3_600_000);
|
|
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
|
|
if (diffMinutes < 1) return 'Just now';
|
|
if (diffMinutes < 60) return `${diffMinutes}m ago`;
|
|
if (diffHours < 24) return `${diffHours}h ago`;
|
|
if (diffDays === 1) return 'Yesterday';
|
|
if (diffDays < 7) return `${diffDays}d ago`;
|
|
|
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
export const ConversationSidebar = forwardRef<ConversationSidebarRef, ConversationSidebarProps>(
|
|
function ConversationSidebar(
|
|
{ isOpen, onClose, currentConversationId, onSelectConversation, onNewConversation },
|
|
ref,
|
|
): React.ReactElement {
|
|
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState('');
|
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const loadSidebarData = useCallback(async (): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
const [loadedConversations, loadedProjects] = await Promise.all([
|
|
api<Conversation[]>('/api/conversations'),
|
|
api<Project[]>('/api/projects').catch(() => [] as Project[]),
|
|
]);
|
|
|
|
setConversations(
|
|
loadedConversations
|
|
.filter((conversation) => !conversation.archived)
|
|
.map(toSummary)
|
|
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
|
);
|
|
setProjects(loadedProjects);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load conversations');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadSidebarData();
|
|
}, [loadSidebarData]);
|
|
|
|
useEffect(() => {
|
|
if (!renamingId) return;
|
|
const timer = window.setTimeout(() => renameInputRef.current?.focus(), 0);
|
|
return () => window.clearTimeout(timer);
|
|
}, [renamingId]);
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
refresh: () => {
|
|
void loadSidebarData();
|
|
},
|
|
addConversation: (conversation) => {
|
|
setConversations((prev) => {
|
|
const next = [conversation, ...prev.filter((item) => item.id !== conversation.id)];
|
|
return next.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
});
|
|
},
|
|
}),
|
|
[loadSidebarData],
|
|
);
|
|
|
|
const filteredConversations = useMemo(() => {
|
|
const query = searchQuery.trim().toLowerCase();
|
|
if (!query) return conversations;
|
|
|
|
return conversations.filter((conversation) =>
|
|
(conversation.title ?? 'Untitled conversation').toLowerCase().includes(query),
|
|
);
|
|
}, [conversations, searchQuery]);
|
|
|
|
const groupedConversations = useMemo<GroupedConversations[]>(() => {
|
|
if (projects.length === 0) {
|
|
return [
|
|
{
|
|
key: 'all',
|
|
label: 'All conversations',
|
|
projectId: null,
|
|
conversations: filteredConversations,
|
|
},
|
|
];
|
|
}
|
|
|
|
const byProject = new Map<string | null, ConversationSummary[]>();
|
|
for (const conversation of filteredConversations) {
|
|
const key = conversation.projectId ?? null;
|
|
const items = byProject.get(key) ?? [];
|
|
items.push(conversation);
|
|
byProject.set(key, items);
|
|
}
|
|
|
|
const groups: GroupedConversations[] = [];
|
|
|
|
for (const project of projects) {
|
|
const projectConversations = byProject.get(project.id);
|
|
if (!projectConversations?.length) continue;
|
|
|
|
groups.push({
|
|
key: project.id,
|
|
label: project.name,
|
|
projectId: project.id,
|
|
conversations: projectConversations,
|
|
});
|
|
}
|
|
|
|
const ungrouped = byProject.get(null);
|
|
if (ungrouped?.length) {
|
|
groups.push({
|
|
key: 'general',
|
|
label: 'General',
|
|
projectId: null,
|
|
conversations: ungrouped,
|
|
});
|
|
}
|
|
|
|
if (groups.length === 0) {
|
|
groups.push({
|
|
key: 'all',
|
|
label: 'All conversations',
|
|
projectId: null,
|
|
conversations: filteredConversations,
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}, [filteredConversations, projects]);
|
|
|
|
const startRename = useCallback((conversation: ConversationSummary): void => {
|
|
setPendingDeleteId(null);
|
|
setRenamingId(conversation.id);
|
|
setRenameValue(conversation.title ?? '');
|
|
}, []);
|
|
|
|
const cancelRename = useCallback((): void => {
|
|
setRenamingId(null);
|
|
setRenameValue('');
|
|
}, []);
|
|
|
|
const commitRename = useCallback(async (): Promise<void> => {
|
|
if (!renamingId) return;
|
|
|
|
const title = renameValue.trim() || 'Untitled conversation';
|
|
|
|
try {
|
|
const updated = await api<Conversation>(`/api/conversations/${renamingId}`, {
|
|
method: 'PATCH',
|
|
body: { title },
|
|
});
|
|
|
|
const summary = toSummary(updated);
|
|
setConversations((prev) =>
|
|
prev
|
|
.map((conversation) => (conversation.id === renamingId ? summary : conversation))
|
|
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)),
|
|
);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to rename conversation');
|
|
} finally {
|
|
setRenamingId(null);
|
|
setRenameValue('');
|
|
}
|
|
}, [renameValue, renamingId]);
|
|
|
|
const deleteConversation = useCallback(
|
|
async (conversationId: string): Promise<void> => {
|
|
try {
|
|
await api<void>(`/api/conversations/${conversationId}`, { method: 'DELETE' });
|
|
setConversations((prev) =>
|
|
prev.filter((conversation) => conversation.id !== conversationId),
|
|
);
|
|
if (currentConversationId === conversationId) {
|
|
onSelectConversation(null);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to delete conversation');
|
|
} finally {
|
|
setPendingDeleteId(null);
|
|
}
|
|
},
|
|
[currentConversationId, onSelectConversation],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{isOpen ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Close conversation sidebar"
|
|
className="fixed inset-0 z-30 bg-black/50 md:hidden"
|
|
onClick={onClose}
|
|
/>
|
|
) : null}
|
|
|
|
<aside
|
|
aria-label="Conversation sidebar"
|
|
className="fixed left-0 top-0 z-40 flex h-full flex-col border-r md:relative md:z-0"
|
|
style={{
|
|
width: 'var(--sidebar-w)',
|
|
background: 'var(--bg)',
|
|
borderColor: 'var(--border)',
|
|
transform: isOpen ? 'translateX(0)' : 'translateX(calc(-1 * var(--sidebar-w)))',
|
|
transition: 'transform 220ms var(--ease)',
|
|
}}
|
|
>
|
|
<div
|
|
className="flex items-center justify-between border-b px-4 py-3"
|
|
style={{ borderColor: 'var(--border)' }}
|
|
>
|
|
<div>
|
|
<p className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
|
|
Conversations
|
|
</p>
|
|
<p className="text-xs" style={{ color: 'var(--muted)' }}>
|
|
Search, rename, and manage threads
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="rounded-md p-2 md:hidden"
|
|
style={{ color: 'var(--text-2)' }}
|
|
>
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
|
<path strokeWidth="2" strokeLinecap="round" d="M6 6l12 12M18 6 6 18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-3 border-b p-3" style={{ borderColor: 'var(--border)' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onNewConversation(null)}
|
|
className="flex w-full items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors"
|
|
style={{
|
|
borderColor: 'var(--primary)',
|
|
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
|
|
color: 'var(--text)',
|
|
}}
|
|
>
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor">
|
|
<path strokeWidth="2" strokeLinecap="round" d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
New conversation
|
|
</button>
|
|
|
|
<div className="relative">
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
style={{ color: 'var(--muted)' }}
|
|
>
|
|
<circle cx="11" cy="11" r="7" strokeWidth="2" />
|
|
<path d="m20 20-3.5-3.5" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
<input
|
|
type="search"
|
|
value={searchQuery}
|
|
onChange={(event) => setSearchQuery(event.target.value)}
|
|
placeholder="Search conversations"
|
|
className="w-full rounded-lg border px-9 py-2 text-sm outline-none"
|
|
style={{
|
|
background: 'var(--surface)',
|
|
borderColor: 'var(--border)',
|
|
color: 'var(--text)',
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
{isLoading ? (
|
|
<div className="py-8 text-center text-sm" style={{ color: 'var(--muted)' }}>
|
|
Loading conversations...
|
|
</div>
|
|
) : error ? (
|
|
<div
|
|
className="space-y-3 rounded-xl border p-4 text-sm"
|
|
style={{
|
|
background: 'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
|
borderColor: 'color-mix(in srgb, var(--danger) 35%, var(--border))',
|
|
color: 'var(--text)',
|
|
}}
|
|
>
|
|
<p>{error}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadSidebarData()}
|
|
className="rounded-md px-3 py-1.5 text-xs font-medium"
|
|
style={{ background: 'var(--danger)', color: 'white' }}
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
) : filteredConversations.length === 0 ? (
|
|
<div className="py-10 text-center">
|
|
<p className="text-sm" style={{ color: 'var(--text-2)' }}>
|
|
{searchQuery ? 'No matching conversations' : 'No conversations yet'}
|
|
</p>
|
|
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
|
{searchQuery ? 'Try another title search.' : 'Start a new conversation to begin.'}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{groupedConversations.map((group) => (
|
|
<section key={group.key} className="space-y-2">
|
|
{projects.length > 0 ? (
|
|
<div className="flex items-center justify-between px-1">
|
|
<h3
|
|
className="text-[11px] font-semibold uppercase tracking-[0.16em]"
|
|
style={{ color: 'var(--muted)' }}
|
|
>
|
|
{group.label}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => onNewConversation(group.projectId)}
|
|
className="rounded-md px-2 py-1 text-[11px] font-medium"
|
|
style={{ color: 'var(--ms-blue-500)' }}
|
|
>
|
|
New
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-1">
|
|
{group.conversations.map((conversation) => {
|
|
const isActive = currentConversationId === conversation.id;
|
|
const isRenaming = renamingId === conversation.id;
|
|
const showActions =
|
|
hoveredId === conversation.id ||
|
|
isRenaming ||
|
|
pendingDeleteId === conversation.id;
|
|
|
|
return (
|
|
<div
|
|
key={conversation.id}
|
|
onMouseEnter={() => setHoveredId(conversation.id)}
|
|
onMouseLeave={() =>
|
|
setHoveredId((current) =>
|
|
current === conversation.id ? null : current,
|
|
)
|
|
}
|
|
className="rounded-xl border p-2 transition-colors"
|
|
style={{
|
|
borderColor: isActive
|
|
? 'color-mix(in srgb, var(--primary) 60%, var(--border))'
|
|
: 'transparent',
|
|
background: isActive ? 'var(--surface-2)' : 'transparent',
|
|
}}
|
|
>
|
|
{isRenaming ? (
|
|
<input
|
|
ref={renameInputRef}
|
|
value={renameValue}
|
|
onChange={(event) => setRenameValue(event.target.value)}
|
|
onBlur={() => void commitRename()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
void commitRename();
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
cancelRename();
|
|
}
|
|
}}
|
|
maxLength={255}
|
|
className="w-full rounded-md border px-2 py-1.5 text-sm outline-none"
|
|
style={{
|
|
background: 'var(--surface)',
|
|
borderColor: 'var(--ms-blue-500)',
|
|
color: 'var(--text)',
|
|
}}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectConversation(conversation.id)}
|
|
className="block w-full text-left"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p
|
|
className="truncate text-sm font-medium"
|
|
style={{
|
|
color: isActive ? 'var(--text)' : 'var(--text-2)',
|
|
}}
|
|
>
|
|
{conversation.title ?? 'Untitled conversation'}
|
|
</p>
|
|
<p className="mt-1 text-xs" style={{ color: 'var(--muted)' }}>
|
|
{formatRelativeTime(conversation.updatedAt)}
|
|
</p>
|
|
</div>
|
|
|
|
{showActions ? (
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
startRename(conversation);
|
|
}}
|
|
className="rounded-md p-1.5 transition-colors"
|
|
style={{ color: 'var(--text-2)' }}
|
|
aria-label={`Rename ${conversation.title ?? 'conversation'}`}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
d="M4 20h4l10.5-10.5a1.4 1.4 0 0 0 0-2L16.5 5.5a1.4 1.4 0 0 0-2 0L4 16v4Z"
|
|
strokeWidth="1.8"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setPendingDeleteId((current) =>
|
|
current === conversation.id ? null : conversation.id,
|
|
);
|
|
setRenamingId(null);
|
|
}}
|
|
className="rounded-md p-1.5 transition-colors"
|
|
style={{ color: 'var(--danger)' }}
|
|
aria-label={`Delete ${conversation.title ?? 'conversation'}`}
|
|
>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className="h-3.5 w-3.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
d="M4 7h16M10 11v6M14 11v6M6 7l1 12h10l1-12M9 7V4h6v3"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
)}
|
|
|
|
{pendingDeleteId === conversation.id ? (
|
|
<div
|
|
className="mt-2 flex items-center justify-between rounded-lg border px-2 py-2"
|
|
style={{
|
|
borderColor:
|
|
'color-mix(in srgb, var(--danger) 45%, var(--border))',
|
|
background:
|
|
'color-mix(in srgb, var(--danger) 10%, var(--surface))',
|
|
}}
|
|
>
|
|
<p className="text-xs" style={{ color: 'var(--text-2)' }}>
|
|
Delete this conversation?
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPendingDeleteId(null)}
|
|
className="rounded-md px-2 py-1 text-xs"
|
|
style={{ color: 'var(--text-2)' }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void deleteConversation(conversation.id)}
|
|
className="rounded-md px-2 py-1 text-xs font-medium"
|
|
style={{ background: 'var(--danger)', color: 'white' }}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
</>
|
|
);
|
|
},
|
|
);
|