- Fork: POST /api/chats/:id/fork creates a new chat in the same session, copies messages up to target (status=complete) with row-offset clock_timestamp() for stable ordering. Client emits open_chat_in_active_pane event; Workspace opens it in the active pane. No maybeAutoNameChat on forks. - Delete: DELETE /api/chats/:id/messages/:message_id with 409 if the chat is currently streaming. Cascading-forward delete (created_at >= target). MessageBubble Trash button + confirm Dialog. - Header: Projects -> Project -> Session breadcrumb, model badge pill, inline session rename, active file path via new useActivePane() hook. Server now publishes session_renamed on PATCH /api/sessions/:id; client-side dup emit removed from Session.tsx. - Housekeeping: NOW() -> clock_timestamp() in schema.sql defaults, dead PaneTab.tsx and panes/PaneShell.tsx removed, session_panes backfill INSERT removed (CREATE TABLE retained), Tailnet trust comment near app.listen(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
|
import { ChevronRight } from 'lucide-react';
|
|
import { api } from '@/api/client';
|
|
import type { Project, Session as SessionType } from '@/api/types';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
import { useActivePane } from '@/hooks/useActivePane';
|
|
import { Workspace } from '@/components/Workspace';
|
|
import { ModelPicker } from '@/components/ModelPicker';
|
|
|
|
export function Session() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const [session, setSession] = useState<SessionType | null>(null);
|
|
const [project, setProject] = useState<Project | null>(null);
|
|
const [name, setName] = useState('');
|
|
const [editingName, setEditingName] = useState(false);
|
|
const active = useActivePane();
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
setSession(null);
|
|
setProject(null);
|
|
let cancelled = false;
|
|
api.sessions
|
|
.get(id)
|
|
.then((s) => {
|
|
if (cancelled) return;
|
|
setSession(s);
|
|
setName(s.name);
|
|
sessionEvents.emit({
|
|
type: 'session_loaded',
|
|
session_id: id,
|
|
project_id: s.project_id,
|
|
});
|
|
// Load project for breadcrumb. Listing is fine — small N, cached by client.
|
|
api.projects.list().then((projects) => {
|
|
if (cancelled) return;
|
|
const p = projects.find((x) => x.id === s.project_id);
|
|
if (p) setProject(p);
|
|
}).catch(() => {});
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
return sessionEvents.subscribe((event) => {
|
|
if (event.type === 'session_renamed' && event.session_id === id) {
|
|
setSession((prev) => (prev ? { ...prev, name: event.name } : prev));
|
|
setName((prev) => (editingName ? prev : event.name));
|
|
return;
|
|
}
|
|
if (
|
|
(event.type === 'session_deleted' || event.type === 'session_archived') &&
|
|
event.session_id === id
|
|
) {
|
|
navigate(`/project/${event.project_id}`);
|
|
}
|
|
});
|
|
}, [id, editingName, navigate]);
|
|
|
|
async function saveName() {
|
|
if (!id || !session) return;
|
|
const trimmed = name.trim();
|
|
if (!trimmed || trimmed === session.name) {
|
|
setName(session.name);
|
|
setEditingName(false);
|
|
return;
|
|
}
|
|
const updated = await api.sessions.update(id, { name: trimmed });
|
|
setSession(updated);
|
|
setEditingName(false);
|
|
// Server publishes session_renamed via broker.publishUser; no local emit needed.
|
|
}
|
|
|
|
// Workspace only sets activeFile for file-browser panes; checking it alone
|
|
// suffices and is forward-compatible with future pane kinds.
|
|
const showActiveFile = active.sessionId === id && !!active.activeFile;
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
<header className="border-b px-4 py-2 flex items-center gap-1.5 shrink-0 text-sm">
|
|
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
|
Projects
|
|
</Link>
|
|
<ChevronRight className="size-3 text-muted-foreground/60" />
|
|
{project ? (
|
|
<Link
|
|
to={`/project/${project.id}`}
|
|
className="text-muted-foreground hover:text-foreground truncate max-w-[200px]"
|
|
title={project.name}
|
|
>
|
|
{project.name}
|
|
</Link>
|
|
) : (
|
|
<span className="text-muted-foreground/60">…</span>
|
|
)}
|
|
<ChevronRight className="size-3 text-muted-foreground/60" />
|
|
{editingName ? (
|
|
<input
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onBlur={() => void saveName()}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void saveName();
|
|
if (e.key === 'Escape') {
|
|
setName(session?.name ?? '');
|
|
setEditingName(false);
|
|
}
|
|
}}
|
|
className="bg-transparent border-b border-border px-1 py-0.5 text-sm font-medium outline-none focus:border-ring"
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="text-sm font-medium hover:underline truncate max-w-[280px]"
|
|
onClick={() => setEditingName(true)}
|
|
title={session?.name ?? ''}
|
|
>
|
|
{session?.name ?? '…'}
|
|
</button>
|
|
)}
|
|
{showActiveFile && active.activeFile && (
|
|
<>
|
|
<span className="text-muted-foreground/40 mx-1">·</span>
|
|
<span
|
|
className="text-xs font-mono text-muted-foreground truncate max-w-[320px]"
|
|
title={active.activeFile}
|
|
>
|
|
{active.activeFile}
|
|
</span>
|
|
</>
|
|
)}
|
|
<div className="ml-auto">
|
|
{session && (
|
|
<div className="inline-flex items-center rounded-full bg-muted/40 hover:bg-muted/70 px-1">
|
|
<ModelPicker
|
|
value={session.model}
|
|
onChange={async (model) => {
|
|
const updated = await api.sessions.update(session.id, { model });
|
|
setSession(updated);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{id && session && (
|
|
<Workspace sessionId={id} projectId={session.project_id} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|