- Fastify global empty-JSON-body parser fixes archive/unarchive/stop 400s - Removed redundant local sessionEvents.emit at all 5+2 sites with server-side WS publishers; added dedupe guards in useSidebar/Workspace/Project handlers - Sidebar session right-click adds Delete (destructive) with confirm Dialog - Session.tsx navigates away on session_deleted/session_archived for the active session - SessionLandingPage chat rows show message_count, effective_context_tokens, last_message_preview via LATERAL joins on GET /api/sessions/:id/chats - Workspace.tsx pane drag-to-reorder using native HTML5 events (no new deps) - CompactCard: Copy toast, Send-to-chat with target chat name, empty-state in share popover, Re-run button - auto_name.ts: filter count gate and assistant-fetch by content <> '' so tool-call assistant rows don't trip the once-and-only-once guard - Adds CLAUDE.md and apps/web/src/lib/format.ts
187 lines
5.8 KiB
TypeScript
187 lines
5.8 KiB
TypeScript
import { useState } from 'react';
|
|
import { MessageSquare, Send, ChevronDown, ChevronRight } from 'lucide-react';
|
|
import type { Chat } from '@/api/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { formatTokens } from '@/lib/format';
|
|
|
|
interface Props {
|
|
sessionId: string;
|
|
projectId: string;
|
|
chats: Chat[];
|
|
onOpenChat: (chatId: string) => void;
|
|
onSend: (content: string) => void;
|
|
onReopenChat: (chatId: string) => Promise<void>;
|
|
}
|
|
|
|
function relTime(iso: string): string {
|
|
const now = Date.now();
|
|
const t = Date.parse(iso);
|
|
if (Number.isNaN(t)) return '';
|
|
const sec = Math.max(0, Math.floor((now - t) / 1000));
|
|
if (sec < 60) return `${sec}s ago`;
|
|
const min = Math.floor(sec / 60);
|
|
if (min < 60) return `${min}m ago`;
|
|
const hr = Math.floor(min / 60);
|
|
if (hr < 24) return `${hr}h ago`;
|
|
const day = Math.floor(hr / 24);
|
|
return `${day}d ago`;
|
|
}
|
|
|
|
function ChatRow({
|
|
chat,
|
|
onClick,
|
|
dimmed,
|
|
trailing,
|
|
}: {
|
|
chat: Chat;
|
|
onClick: () => void;
|
|
dimmed?: boolean;
|
|
trailing?: string;
|
|
}) {
|
|
const meta: string[] = [relTime(chat.updated_at)];
|
|
if (chat.message_count !== undefined && chat.message_count > 0) {
|
|
meta.push(`${chat.message_count} msg`);
|
|
}
|
|
const tokens = formatTokens(chat.effective_context_tokens);
|
|
if (tokens) meta.push(tokens);
|
|
const preview = chat.last_message_preview;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className="w-full flex flex-col gap-0.5 px-3 py-2 hover:bg-muted/50 text-left"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
|
|
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
|
|
{chat.name ?? 'New chat'}
|
|
</span>
|
|
{trailing && (
|
|
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
|
|
)}
|
|
</div>
|
|
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
|
|
{meta.join(' · ')}
|
|
</div>
|
|
{preview && (
|
|
<div className="ml-5 text-xs italic text-muted-foreground truncate">
|
|
{preview}
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function SessionLandingPage({
|
|
chats,
|
|
onOpenChat,
|
|
onSend,
|
|
onReopenChat,
|
|
}: Props) {
|
|
const [composerValue, setComposerValue] = useState('');
|
|
const [showClosed, setShowClosed] = useState(false);
|
|
|
|
const openChats = chats
|
|
.filter((c) => c.status === 'open')
|
|
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
|
const closedChats = chats
|
|
.filter((c) => c.status === 'closed')
|
|
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
|
|
|
|
function handleSend() {
|
|
const text = composerValue.trim();
|
|
if (!text) return;
|
|
onSend(text);
|
|
setComposerValue('');
|
|
}
|
|
|
|
// TODO: Landing page chat counts are a snapshot at mount. New messages in
|
|
// visible chats won't update the per-row stats until next mount/navigation.
|
|
// Wiring WS reactivity through here is deferred (rare use case: user is in
|
|
// a pane when messages stream, not on the landing page).
|
|
return (
|
|
<div className="flex flex-col h-full min-h-0">
|
|
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
|
{/* Open chats */}
|
|
{openChats.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
|
|
<ul className="divide-y rounded-md border">
|
|
{openChats.map((chat) => (
|
|
<li key={chat.id}>
|
|
<ChatRow chat={chat} onClick={() => onOpenChat(chat.id)} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Closed chats */}
|
|
{closedChats.length > 0 && (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowClosed(!showClosed)}
|
|
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
|
|
>
|
|
{showClosed ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
|
Closed chats ({closedChats.length})
|
|
</button>
|
|
{showClosed && (
|
|
<ul className="divide-y rounded-md border">
|
|
{closedChats.map((chat) => (
|
|
<li key={chat.id}>
|
|
<ChatRow
|
|
chat={chat}
|
|
onClick={() => void onReopenChat(chat.id)}
|
|
dimmed
|
|
trailing="Reopen"
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{openChats.length === 0 && closedChats.length === 0 && (
|
|
<div className="text-sm text-muted-foreground py-8 text-center">
|
|
No chats yet. Type below to start a conversation.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Composer */}
|
|
<div className="border-t px-4 py-3 flex items-end gap-2 shrink-0">
|
|
<Textarea
|
|
value={composerValue}
|
|
onChange={(e) => setComposerValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
return;
|
|
}
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
}}
|
|
placeholder="Start a new chat..."
|
|
rows={2}
|
|
className="resize-none min-h-[52px] max-h-[160px]"
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!composerValue.trim()}
|
|
size="icon-lg"
|
|
aria-label="Send"
|
|
>
|
|
<Send />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|