feat(web): conversation sidebar with search, rename, delete (#222)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
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>
This commit was merged in pull request #222.
This commit is contained in:
576
apps/web/src/components/chat/conversation-sidebar.tsx
Normal file
576
apps/web/src/components/chat/conversation-sidebar.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
'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>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user