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:
2026-05-15 20:39:48 +00:00
parent 6d9515b8a5
commit c35ec65fc4
37 changed files with 3290 additions and 1012 deletions

View File

@@ -2,9 +2,9 @@ import { Children, cloneElement, isValidElement, useState } from 'react';
import type { ReactElement, ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Copy, RefreshCw, Check } from 'lucide-react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Message } from '@/api/types';
import type { Chat, Message } from '@/api/types';
import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { ToolCallCard } from './ToolCallCard';
@@ -84,7 +84,7 @@ function linkifyChildren(children: ReactNode, keyPrefix = 'l'): ReactNode {
interface Props {
message: Message;
sessionId: string;
sessionChats?: Chat[];
}
function MarkdownBody({ content }: { content: string }) {
@@ -193,10 +193,8 @@ function StatsLine({ message }: { message: Message }) {
function ActionRow({
message,
sessionId,
}: {
message: Message;
sessionId: string;
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
@@ -215,7 +213,7 @@ function ActionRow({
if (regenerating || message.status === 'streaming') return;
setRegenerating(true);
try {
await api.messages.regenerate(sessionId, message.id);
await api.messages.regenerate(message.chat_id, message.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally {
@@ -253,7 +251,101 @@ function ActionRow({
);
}
export function MessageBubble({ message, sessionId }: Props) {
function CompactCard({ message, sessionChats }: { message: Message; sessionChats?: Chat[] }) {
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const headerMatch = message.content.match(/^\[Context compacted — (\d+) messages summarized\]/);
const headerText = headerMatch ? headerMatch[0] : 'Context compacted';
const summaryText = headerMatch
? message.content.slice(headerMatch[0].length).trim()
: message.content;
async function handleCopy() {
try {
await navigator.clipboard.writeText(summaryText);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch {
toast.error('Copy failed');
}
}
async function handleShareToChat(chatId: string) {
try {
await api.messages.send(chatId, summaryText);
toast.success('Summary sent to chat');
setShareOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to share');
}
}
const otherChats = (sessionChats ?? []).filter(
(c) => c.id !== message.chat_id && c.status === 'open'
);
return (
<div className="rounded-lg border bg-muted/30 text-sm">
<div className="flex items-center gap-2 px-3 py-2">
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="text-xs font-medium truncate">{headerText}</span>
</button>
<button
type="button"
onClick={() => void handleCopy()}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Copy summary"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
{otherChats.length > 0 && (
<div className="relative">
<button
type="button"
onClick={() => setShareOpen(!shareOpen)}
className="p-1 rounded hover:bg-muted text-muted-foreground"
aria-label="Send to chat"
>
<Share2 size={12} />
</button>
{shareOpen && (
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border rounded-md shadow-md min-w-[160px] py-1">
{otherChats.map((c) => (
<button
key={c.id}
type="button"
onClick={() => void handleShareToChat(c.id)}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent truncate"
>
{c.name ?? 'New chat'}
</button>
))}
</div>
)}
</div>
)}
</div>
{expanded && (
<div className="px-3 pb-3 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap border-t pt-2">
{summaryText}
</div>
)}
</div>
);
}
export function MessageBubble({ message, sessionChats }: Props) {
if (message.kind === 'compact') {
return <CompactCard message={message} sessionChats={sessionChats} />;
}
if (message.role === 'tool') {
return <ToolCallCard message={message} />;
}
@@ -264,7 +356,7 @@ export function MessageBubble({ message, sessionId }: Props) {
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap">
{message.content}
</div>
<ActionRow message={message} sessionId={sessionId} />
<ActionRow message={message} />
</div>
);
}
@@ -292,7 +384,7 @@ export function MessageBubble({ message, sessionId }: Props) {
)}
{!isStreaming && <StatsLine message={message} />}
{!isStreaming && (hasContent || hasToolCalls) && (
<ActionRow message={message} sessionId={sessionId} />
<ActionRow message={message} />
)}
</div>
);