batch4: chats-in-sessions, force-send, /compact, right-rail file browser
Session 1:N Chat data model with backfill. Workspace switches to client-side multi-tab pane management. Right-rail file browser with float-over viewer and click-drag line selection replaces FileBrowserPane. Adds /compact streaming summarizer (respects compact markers in context builder), force-send (cancels in-flight, persists partial as 'cancelled', awaits cancellation completion via deferred Promise + 5s timeout), message queue, stop generation, chat auto-rename, session archive/unarchive with Closed Sessions section on repo landing page. CHECK constraints on sessions.status, messages.role, messages.status with KEEP IN SYNC comments tying to MESSAGE_ROLES / MESSAGE_STATUSES const arrays. Deletes dead pane routes/hook and the api.panes.* client block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
155
apps/web/src/components/SessionLandingPage.tsx
Normal file
155
apps/web/src/components/SessionLandingPage.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
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';
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChat(chat.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left"
|
||||
>
|
||||
<MessageSquare className="size-3.5 opacity-70 shrink-0" />
|
||||
<span className="truncate text-sm flex-1">
|
||||
{chat.name ?? 'New chat'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
||||
{relTime(chat.updated_at)}
|
||||
</span>
|
||||
</button>
|
||||
</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}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onReopenChat(chat.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-muted/50 text-left"
|
||||
>
|
||||
<MessageSquare className="size-3.5 opacity-40 shrink-0" />
|
||||
<span className="truncate text-sm flex-1 text-muted-foreground">
|
||||
{chat.name ?? 'New chat'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
Reopen
|
||||
</span>
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user