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:
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Plus, MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { Plus, MessageSquare, Trash2, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api/client';
|
||||
import type { Project as ProjectType } from '@/api/types';
|
||||
import type { Project as ProjectType, Session } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
@@ -14,6 +14,8 @@ export function Project() {
|
||||
const { sessions, create, remove } = useSessions(id);
|
||||
const [project, setProject] = useState<ProjectType | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [archivedSessions, setArchivedSessions] = useState<Session[] | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
@@ -23,6 +25,26 @@ export function Project() {
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.sessions.listForProject(id, 'archived')
|
||||
.then(setArchivedSessions)
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'session_archived' && event.project_id === id) {
|
||||
setArchivedSessions((prev) => {
|
||||
if (!prev) return prev;
|
||||
const session = sessions?.find((s) => s.id === event.session_id);
|
||||
if (!session) return prev;
|
||||
return [{ ...session, status: 'archived' as const }, ...prev];
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [id, sessions]);
|
||||
|
||||
async function handleNew() {
|
||||
if (!id || creating) return;
|
||||
setCreating(true);
|
||||
@@ -35,6 +57,17 @@ export function Project() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUnarchive(sessionId: string) {
|
||||
try {
|
||||
await api.sessions.unarchive(sessionId);
|
||||
setArchivedSessions((prev) =>
|
||||
prev ? prev.filter((s) => s.id !== sessionId) : prev
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'failed to unarchive');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<header className="border-b px-6 py-3 flex items-center justify-between">
|
||||
@@ -52,7 +85,7 @@ export function Project() {
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{sessions === null && (
|
||||
<div className="text-sm text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
@@ -97,6 +130,61 @@ export function Project() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Archived sessions */}
|
||||
{archivedSessions && archivedSessions.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
|
||||
>
|
||||
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
Closed sessions ({archivedSessions.length})
|
||||
</button>
|
||||
{showArchived && (
|
||||
<ul className="divide-y rounded-md border">
|
||||
{archivedSessions.map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<MessageSquare className="size-3.5 opacity-40 shrink-0" />
|
||||
<span className="truncate text-sm text-muted-foreground">{s.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Reopen session"
|
||||
onClick={() => void handleUnarchive(s.id)}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Delete session permanently"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.sessions.remove(s.id);
|
||||
setArchivedSessions((prev) =>
|
||||
prev ? prev.filter((a) => a.id !== s.id) : prev
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'failed to delete'
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user