From 0193fe5f0faf46e2342ea9ca86c2b20671c7e79d Mon Sep 17 00:00:00 2001 From: Arsen Muk Date: Tue, 11 Aug 2026 12:03:32 +0100 Subject: [PATCH 1/2] =?UTF-8?q?Chat=20sidebar:=20parent=E2=86=92children?= =?UTF-8?q?=20session=20nesting=20via=20drag-and-drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the existing sessions.parent_session_id column (v003) as a display hierarchy: drag one session row onto another to nest it as a child. A parent shows an expand/collapse chevron in place of its icon and its children render indented — unbounded depth, the client draws exactly the hierarchy the server returns. Children nest wherever the parent renders, including the pinned Starred group. Archived/System stay in their own groups, untouched. Un-parent via the row menu "Remove from parent" or by dropping a nested row onto the "remove from parent" strip that appears while dragging one. Expand state persists in localStorage; the active session's ancestors auto-expand so it is never hidden inside a collapsed parent. Backend: PATCH /api/sessions/{id} now accepts parent_session_id (null clears it), guarded against self-parent, unknown parent, cycles, and the 'created' fork window so a display drag never turns a not-yet-started session into a fork. parent_session_id already rides in the feed payload (SELECT *), so an unlimited page size shows the full tree. Co-Authored-By: Claude Opus 4.8 --- nerve/gateway/routes/sessions.py | 47 ++- tests/test_session_parent_patch.py | 113 +++++++ web/src/api/client.ts | 2 +- web/src/components/Chat/SessionSidebar.tsx | 366 ++++++++++++++++++--- web/src/stores/chatStore.ts | 25 ++ web/src/utils/dateGroups.ts | 24 ++ 6 files changed, 521 insertions(+), 56 deletions(-) create mode 100644 tests/test_session_parent_patch.py diff --git a/nerve/gateway/routes/sessions.py b/nerve/gateway/routes/sessions.py index 3645becf..8e001585 100644 --- a/nerve/gateway/routes/sessions.py +++ b/nerve/gateway/routes/sessions.py @@ -351,9 +351,33 @@ async def get_messages(session_id: str, limit: int = 500, user: dict = Depends(r return {"messages": messages, "last_usage": last_usage} +async def _would_create_cycle(db, child_id: str, new_parent_id: str) -> bool: + """True if making ``child_id`` a child of ``new_parent_id`` forms a loop. + + Walks up the ancestor chain from the proposed parent; reaching ``child_id`` + means the drop would nest a session under its own descendant. The seen-set + also breaks any pre-existing cycle so the walk always terminates. + """ + seen: set[str] = set() + cursor: str | None = new_parent_id + while cursor: + if cursor == child_id: + return True + if cursor in seen: + break + seen.add(cursor) + row = await db.get_session(cursor) + cursor = row.get("parent_session_id") if row else None + return False + + @router.patch("/api/sessions/{session_id}") async def update_session(session_id: str, req: dict, user: dict = Depends(require_auth)): - """Update session fields (title, starred, model). + """Update session fields (title, starred, model, parent_session_id). + + ``parent_session_id`` is the sidebar drag-to-nest relationship (null clears + it → top-level). It is display-only: guarded against self/cycle links and + against the ``created`` fork window so a drag never alters execution. ``model`` re-points THIS session only — the composer's picker is per-chat, not a global preference. The engine re-reads the session @@ -395,6 +419,27 @@ async def update_session(session_id: str, req: dict, user: dict = Depends(requir except BackendError as e: raise HTTPException(status_code=400, detail=str(e)) from e fields["model"] = requested_model + if "parent_session_id" in req: + raw = req["parent_session_id"] + new_parent = str(raw).strip() if raw not in (None, "") else None + if new_parent is not None: + if new_parent == session_id: + raise HTTPException(status_code=400, detail="A session cannot be its own parent") + if not await deps.db.get_session(new_parent): + raise HTTPException(status_code=404, detail="Parent session not found") + # Fork-window guard: the engine reads parent_session_id to fork a + # brand-new session's first turn ONLY while status == 'created'. + # Refuse to set a parent in that window so a display drag can never + # turn a not-yet-started session into a fork. Anything already run + # (everything draggable in the sidebar) is unaffected. + if session.get("status") == "created": + raise HTTPException( + status_code=409, + detail="Send a first message before nesting this session", + ) + if await _would_create_cycle(deps.db, session_id, new_parent): + raise HTTPException(status_code=400, detail="That drop would create a cycle") + fields["parent_session_id"] = new_parent if not fields: raise HTTPException(status_code=400, detail="No valid fields to update") old_starred = int(session.get("starred") or 0) diff --git a/tests/test_session_parent_patch.py b/tests/test_session_parent_patch.py new file mode 100644 index 00000000..45f24261 --- /dev/null +++ b/tests/test_session_parent_patch.py @@ -0,0 +1,113 @@ +"""HTTP tests for the sidebar drag-to-nest PATCH (``parent_session_id``). + +Covers the write path added for the session-hierarchy feature in +``gateway/routes/sessions.py``: setting a parent, clearing it (null → +top-level), and the guards — self-parent, unknown parent, cycle, and the +fork-window (a session still in ``created`` status can't be nested, so a +display drag never turns a not-yet-started session into a fork). + +Harness mirrors TestSidebarListRoutes in test_sidebar_pagination_api.py: a +minimal FastAPI app with the real router, auth disabled via an empty jwt_secret. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import pytest_asyncio + +from nerve.agent.sessions import SessionManager +from nerve.db import Database + + +@pytest.mark.asyncio +class TestSessionParentPatch: + @pytest_asyncio.fixture + async def setup(self, db: Database): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + import nerve.config as cfg_mod + from nerve.config import NerveConfig + from nerve.gateway.routes._deps import init_deps + from nerve.gateway.routes.sessions import router as sessions_router + + cfg = NerveConfig() + cfg.auth.jwt_secret = "" # require_auth becomes a no-op + cfg_mod._config = cfg + + sm = SessionManager(db) + engine = SimpleNamespace(config=cfg, sessions=sm) + init_deps(engine=engine, db=db) # type: ignore[arg-type] + + app = FastAPI() + app.include_router(sessions_router) + yield SimpleNamespace(client=TestClient(app), db=db, sm=sm, cfg=cfg) + + cfg_mod._config = None + + async def _mk(self, setup, sid: str, *, started: bool = True) -> None: + """Create a web session. By default move it out of 'created' so it is + nestable — mirrors any session that has run at least once (the only + kind the sidebar exposes as a stable, draggable row).""" + await setup.sm.get_or_create(sid, source="web") + if started: + await setup.db.update_session_fields(sid, {"status": "idle"}) + + async def test_set_then_clear_parent(self, setup): + await self._mk(setup, "parent") + await self._mk(setup, "child") + + r = setup.client.patch("/api/sessions/child", json={"parent_session_id": "parent"}) + assert r.status_code == 200 + assert r.json()["parent_session_id"] == "parent" + + # It also rides in the feed payload the sidebar reads (SELECT * → dict). + feed = setup.client.get("/api/sessions").json()["sessions"] + child = next(s for s in feed if s["id"] == "child") + assert child["parent_session_id"] == "parent" + + # null clears it → back to top-level. + r = setup.client.patch("/api/sessions/child", json={"parent_session_id": None}) + assert r.status_code == 200 + assert r.json()["parent_session_id"] is None + + async def test_self_parent_rejected(self, setup): + await self._mk(setup, "solo") + r = setup.client.patch("/api/sessions/solo", json={"parent_session_id": "solo"}) + assert r.status_code == 400 + + async def test_unknown_parent_rejected(self, setup): + await self._mk(setup, "child") + r = setup.client.patch("/api/sessions/child", json={"parent_session_id": "ghost"}) + assert r.status_code == 404 + + async def test_cycle_rejected(self, setup): + # b becomes a child of a; making a a child of b would close a loop. + await self._mk(setup, "a") + await self._mk(setup, "b") + assert setup.client.patch( + "/api/sessions/b", json={"parent_session_id": "a"}).status_code == 200 + r = setup.client.patch("/api/sessions/a", json={"parent_session_id": "b"}) + assert r.status_code == 400 + + async def test_created_session_cannot_be_nested(self, setup): + # A brand-new session still in 'created' is fork-eligible; refuse to + # re-parent it so a drag can never turn it into a fork. + await self._mk(setup, "parent") + await self._mk(setup, "fresh", started=False) # stays 'created' + r = setup.client.patch("/api/sessions/fresh", json={"parent_session_id": "parent"}) + assert r.status_code == 409 + + async def test_deep_chain_allowed(self, setup): + # Arbitrary depth is fine: a → b → c. + for sid in ("a", "b", "c"): + await self._mk(setup, sid) + assert setup.client.patch( + "/api/sessions/b", json={"parent_session_id": "a"}).status_code == 200 + assert setup.client.patch( + "/api/sessions/c", json={"parent_session_id": "b"}).status_code == 200 + feed = {s["id"]: s for s in setup.client.get("/api/sessions").json()["sessions"]} + assert feed["b"]["parent_session_id"] == "a" + assert feed["c"]["parent_session_id"] == "b" diff --git a/web/src/api/client.ts b/web/src/api/client.ts index aa578633..0c7e89ad 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -382,7 +382,7 @@ export const api = { ), deleteSession: (id: string) => request(`/sessions/${id}`, { method: 'DELETE' }), - updateSession: (id: string, data: { title?: string; starred?: boolean; model?: string }) => + updateSession: (id: string, data: { title?: string; starred?: boolean; model?: string; parent_session_id?: string | null }) => request(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), getMessages: (sessionId: string, limit = 100) => request<{ messages: any[]; last_usage?: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number; cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }; max_context_tokens: number; num_turns?: number } }>(`/sessions/${sessionId}/messages?limit=${limit}`), diff --git a/web/src/components/Chat/SessionSidebar.tsx b/web/src/components/Chat/SessionSidebar.tsx index a87a7b9e..a232a5bb 100644 --- a/web/src/components/Chat/SessionSidebar.tsx +++ b/web/src/components/Chat/SessionSidebar.tsx @@ -1,8 +1,8 @@ import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { Plus, X, MessageSquare, ChevronRight, ChevronDown, Bot, Loader2, Search, Hammer, MoreHorizontal, Star, Pencil, Trash2, Archive, ArchiveRestore, Repeat } from 'lucide-react'; +import { Plus, X, MessageSquare, ChevronRight, ChevronDown, Bot, Loader2, Search, Hammer, MoreHorizontal, Star, Pencil, Trash2, Archive, ArchiveRestore, Repeat, Unlink } from 'lucide-react'; import type { Session, AgentStatus } from '../../types/chat'; -import { groupByDate, parseTimestamp, loadCollapsedGroups, saveCollapsedGroups } from '../../utils/dateGroups'; +import { groupByDate, parseTimestamp, loadCollapsedGroups, saveCollapsedGroups, loadExpandedParents, saveExpandedParents } from '../../utils/dateGroups'; import { useChatStore } from '../../stores/chatStore'; import { useModalSurface } from '../../hooks/useModalSurface'; import { safeAreaInsets } from '../../utils/safeArea'; @@ -38,6 +38,21 @@ function formatShortDate(dateStr: string): string { // group's visible label) lives in utils/dateGroups next to the bucket // taxonomy and its default-collapsed set. +/** Order by last message activity, newest first (opening/starring doesn't bump it). */ +const byUpdatedDesc = (a: Session, b: Session) => + parseTimestamp(b.updated_at).getTime() - parseTimestamp(a.updated_at).getTime(); + +/** Drag-to-nest wiring shared by every draggable session row. */ +type RowDnd = { + draggingId: string | null; + dragOverId: string | null; + onDragStart: (id: string) => void; + onDragEnd: () => void; + onDragOver: (id: string) => void; + onDragLeave: (id: string) => void; + onDrop: (id: string) => void; +}; + export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, onDelete, collapsed, mobile = false, onRequestClose }: { sessions: Session[]; activeSession: string; @@ -55,6 +70,14 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, // so every reload starts collapsed and fetches nothing until expanded. const [archivedExpanded, setArchivedExpanded] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState>(loadCollapsedGroups); + // Per-session expand state for parent→children nesting (persisted, keyed by + // parent id). Drag state for the nest gesture: draggingId = the row being + // dragged, dragOverId = the row it's hovering (the drop target highlight), + // rootDropActive = the "remove from parent" strip is hovered. + const [expandedParents, setExpandedParents] = useState>(loadExpandedParents); + const [draggingId, setDraggingId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + const [rootDropActive, setRootDropActive] = useState(false); const [localQuery, setLocalQuery] = useState(''); const [searchHovered, setSearchHovered] = useState(false); const [searchFocused, setSearchFocused] = useState(false); @@ -67,7 +90,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, const debounceRef = useRef | null>(null); const inputRef = useRef(null); - const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar, archiveSession, virtualSession, discardVirtualSession, sidebarWidth, setSidebarWidth, sessionsHasMore, loadMoreSessions, archivedSessions, archivedCount, archivedLoading, archivedHasMore, loadArchivedSessions, clearArchivedSessions, unarchiveSession, starArchivedSession, systemSessions, systemCount, systemLoading, systemHasMore, loadSystemSessions, clearSystemSessions } = useChatStore(); + const { searchResults, searchLoading, searchSessions, clearSearch, renameSession, toggleStar, archiveSession, setSessionParent, virtualSession, discardVirtualSession, sidebarWidth, setSidebarWidth, sessionsHasMore, loadMoreSessions, archivedSessions, archivedCount, archivedLoading, archivedHasMore, loadArchivedSessions, clearArchivedSessions, unarchiveSession, starArchivedSession, systemSessions, systemCount, systemLoading, systemHasMore, loadSystemSessions, clearSystemSessions } = useChatStore(); const searchFocusNonce = useChatStore(s => s.searchFocusNonce); // In drawer mode the list is a modal overlay: it needs focus, Tab @@ -212,22 +235,46 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, // "last message activity" (opening/starring a chat doesn't bump it), so // browsing never reshuffles the list. Sort explicitly rather than trusting // API array order to keep that invariant regardless of fetch shape. + // Nest children under their parent: a session whose parent_session_id resolves + // to another session in the feed renders only beneath that parent (recursively, + // to whatever depth the data carries — no client-side cap). A session whose + // parent isn't in the feed (not returned under the page limit, archived, or + // deleted) stays top-level, so the client draws exactly the hierarchy the + // server sent and nothing more. + const { childrenByParent, topLevel } = useMemo(() => { + const byId = new Map(conversations.map(s => [s.id, s])); + const kids = new Map(); + const top: Session[] = []; + for (const s of conversations) { + const pid = s.parent_session_id; + if (pid && pid !== s.id && byId.has(pid)) { + const arr = kids.get(pid); + if (arr) arr.push(s); else kids.set(pid, [s]); + } else { + top.push(s); + } + } + for (const arr of kids.values()) arr.sort(byUpdatedDesc); + return { childrenByParent: kids, topLevel: top }; + }, [conversations]); + + // Split TOP-LEVEL conversations into pinned Running / Starred / rest. Children + // never appear here — they ride under their parent wherever it renders, + // including inside the pinned Starred group. const { pinnedRunning, pinnedStarred, restConversations } = useMemo(() => { const running: Session[] = []; const starred: Session[] = []; const rest: Session[] = []; - for (const s of conversations) { + for (const s of topLevel) { const isRunning = s.id === activeSession ? activeIsRunning : !!s.is_running; if (isRunning) running.push(s); else if (s.starred) starred.push(s); else rest.push(s); } - const byUpdatedDesc = (a: Session, b: Session) => - parseTimestamp(b.updated_at).getTime() - parseTimestamp(a.updated_at).getTime(); starred.sort(byUpdatedDesc); rest.sort(byUpdatedDesc); return { pinnedRunning: running, pinnedStarred: starred, restConversations: rest }; - }, [conversations, activeSession, activeIsRunning]); + }, [topLevel, activeSession, activeIsRunning]); const groupedConversations = useMemo(() => groupByDate(restConversations), [restConversations]); @@ -243,6 +290,84 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, }); }, []); + // Expand/collapse one parent's children, persisting so it survives reload. + const toggleExpandParent = useCallback((id: string) => { + setExpandedParents(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + saveExpandedParents(next); + return next; + }); + }, []); + + const handleRemoveParent = useCallback((id: string) => { + setSessionParent(id, null); + }, [setSessionParent]); + + // Drop a dragged row ONTO a target row → nest it under the target and reveal + // it. No-op on self; the server rejects self/cycle links and the store's + // optimistic update reverts on rejection. + const handleRowDrop = useCallback((targetId: string) => { + const src = draggingId; + setDragOverId(null); + setDraggingId(null); + setRootDropActive(false); + if (!src || src === targetId) return; + setSessionParent(src, targetId); + setExpandedParents(prev => { + if (prev.has(targetId)) return prev; + const next = new Set(prev); + next.add(targetId); + saveExpandedParents(next); + return next; + }); + }, [draggingId, setSessionParent]); + + // Drop onto the "remove from parent" strip → clear the parent (→ top-level). + const handleUnparentDrop = useCallback(() => { + const src = draggingId; + setRootDropActive(false); + setDragOverId(null); + setDraggingId(null); + if (src) setSessionParent(src, null); + }, [draggingId, setSessionParent]); + + const dnd: RowDnd = { + draggingId, + dragOverId, + onDragStart: (id) => setDraggingId(id), + onDragEnd: () => { setDraggingId(null); setDragOverId(null); setRootDropActive(false); }, + onDragOver: (id) => setDragOverId(id), + onDragLeave: (id) => setDragOverId(cur => (cur === id ? null : cur)), + onDrop: handleRowDrop, + }; + + // The un-parent drop strip shows only while dragging a row that HAS a parent. + const draggedHasParent = !!(draggingId && conversations.find(s => s.id === draggingId)?.parent_session_id); + + // One top-level feed row + its nested subtree (Running / Starred / date + // buckets all render through this so nesting + drag behave identically). + const renderTree = (s: Session) => ( + + ); + // Never leave the active session hidden inside a collapsed group: when the // active session changes, expand whichever group holds it — once, so a later // manual collapse of that same group still sticks. @@ -265,6 +390,30 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, }); }, [activeSession, pinnedRunning, pinnedStarred, groupedConversations]); + // Never leave the active session hidden inside a collapsed parent: expand its + // whole ancestor chain when it (or the feed) changes. The seen-set guards any + // pre-existing cycle so the walk always ends. + useEffect(() => { + if (!activeSession) return; + const byId = new Map(conversations.map(s => [s.id, s])); + const toOpen: string[] = []; + const seen = new Set(); + let cur = byId.get(activeSession)?.parent_session_id; + while (cur && byId.has(cur) && !seen.has(cur)) { + seen.add(cur); + toOpen.push(cur); + cur = byId.get(cur)?.parent_session_id; + } + if (toOpen.length === 0) return; + setExpandedParents(prev => { + if (toOpen.every(id => prev.has(id))) return prev; + const next = new Set(prev); + toOpen.forEach(id => next.add(id)); + saveExpandedParents(next); + return next; + }); + }, [activeSession, conversations]); + // (System sessions load lazily now — no running-count badge / auto-expand.) return ( @@ -438,19 +587,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, tone="text-emerald-600/70" onToggle={() => toggleGroup('Running')} /> - {!collapsedGroups.has('Running') && pinnedRunning.map((s) => ( - - ))} + {!collapsedGroups.has('Running') && pinnedRunning.map(renderTree)} )} @@ -465,19 +602,7 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, tone="text-yellow-600/70" onToggle={() => toggleGroup('Starred')} /> - {!collapsedGroups.has('Starred') && pinnedStarred.map((s) => ( - - ))} + {!collapsedGroups.has('Starred') && pinnedStarred.map(renderTree)} )} @@ -494,25 +619,31 @@ export function SessionSidebar({ sessions, activeSession, agentStatus, onCreate, collapsed={collapsedGroups.has(group)} onToggle={() => toggleGroup(group)} /> - {!collapsedGroups.has(group) && items.map((s) => ( - - ))} + {!collapsedGroups.has(group) && items.map(renderTree)} ))} {/* Feed page window exhausted — never truncate silently. */} {sessionsHasMore && } + {/* Un-parent target: drop a nested row here to make it top-level. + Shown only while dragging a row that has a parent — a drop lands + here (not on a row) purely by event bubbling, no position math. */} + {draggingId && draggedHasParent && ( +
{ e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setRootDropActive(true); }} + onDragLeave={() => setRootDropActive(false)} + onDrop={(e) => { e.preventDefault(); e.stopPropagation(); handleUnparentDrop(); }} + className={`mx-1 my-1 px-3 py-2 rounded-md border border-dashed text-[11px] text-center transition-colors ${ + rootDropActive + ? 'border-accent text-accent bg-accent/10' + : 'border-border-subtle text-text-faint' + }`} + > + Drop here to remove from parent +
+ )} + {/* System sessions (cron/hook) — lazy: nothing is fetched until the group is expanded, and collapsing drops the rows again, so the next expand repeats the identical request. */} @@ -765,7 +896,79 @@ function StatusIndicator({ session, isActive, isRunning }: { } -function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onUnarchive, onStarArchived, archived, onSelect, showDate }: { +/** + * One feed row plus its nested subtree: renders the row via SessionItem, then — + * when it has children and is expanded — recurses for each child at depth+1. + * Depth is unbounded; the client draws whatever hierarchy the server returned. + */ +function SessionTree({ + session, depth, childrenByParent, expandedParents, onToggleExpand, + activeSession, activeIsRunning, dnd, + onDelete, onRename, onToggleStar, onArchive, onRemoveParent, onSelect, +}: { + session: Session; + depth: number; + childrenByParent: Map; + expandedParents: Set; + onToggleExpand: (id: string) => void; + activeSession: string; + activeIsRunning: boolean; + dnd: RowDnd; + onDelete: (id: string) => void; + onRename: (id: string, title: string) => Promise; + onToggleStar: (id: string) => Promise; + onArchive: (id: string) => Promise; + onRemoveParent: (id: string) => void; + onSelect?: () => void; +}) { + const kids = childrenByParent.get(session.id); + const hasChildren = !!kids && kids.length > 0; + const expanded = expandedParents.has(session.id); + return ( + <> + + {hasChildren && expanded && kids!.map(k => ( + + ))} + + ); +} + + +function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggleStar, onArchive, onUnarchive, onStarArchived, archived, onSelect, showDate, + depth = 0, hasChildren = false, expanded = false, onToggleExpand, onRemoveParent, draggable = false, dnd }: { session: Session; isActive: boolean; isRunning: boolean; @@ -779,6 +982,16 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl /** Fired when the row itself is opened (not its menu) — drawer mode uses it to close. */ onSelect?: () => void; showDate?: boolean; + /** Nesting: depth (0 = top level) indents the row; a parent shows a + chevron in place of its icon that toggles its children. */ + depth?: number; + hasChildren?: boolean; + expanded?: boolean; + onToggleExpand?: (id: string) => void; + onRemoveParent?: (id: string) => void; + /** Drag-to-nest: only feed rows are draggable; search/archived rows aren't. */ + draggable?: boolean; + dnd?: RowDnd; }) { const [menuOpen, setMenuOpen] = useState(false); const [renaming, setRenaming] = useState(false); @@ -832,22 +1045,53 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl ); } + const isDragSource = dnd?.draggingId === session.id; + const isDropTarget = !!dnd && dnd.dragOverId === session.id + && !!dnd.draggingId && dnd.draggingId !== session.id; + return ( { + // Override the browser's default drag with our session id. + e.dataTransfer.setData('text/plain', session.id); + e.dataTransfer.effectAllowed = 'move'; + dnd.onDragStart(session.id); + } : undefined} + onDragEnd={draggable && dnd ? () => dnd.onDragEnd() : undefined} + onDragOver={dnd ? (e) => { + if (!dnd.draggingId || dnd.draggingId === session.id) return; + e.preventDefault(); // allow the drop + e.dataTransfer.dropEffect = 'move'; + dnd.onDragOver(session.id); + } : undefined} + onDragLeave={dnd ? () => dnd.onDragLeave(session.id) : undefined} + onDrop={dnd ? (e) => { e.preventDefault(); e.stopPropagation(); dnd.onDrop(session.id); } : undefined} + style={depth ? { paddingLeft: 12 + depth * 16 } : undefined} className={`group flex items-center gap-2 px-3 py-1.5 mx-1 rounded-md cursor-pointer text-sm transition-colors no-underline ${isActive ? 'bg-accent/10 text-text' : 'text-text-muted hover:bg-surface-raised hover:text-text-secondary' - }`} + }${isDropTarget ? ' ring-2 ring-inset ring-accent bg-accent/10' : ''}${isDragSource ? ' opacity-50' : ''}`} > - {session.review_loop - ? - : isImplementSession(session) - ? - : - } + {hasChildren ? ( + + ) : session.review_loop ? ( + + ) : isImplementSession(session) ? ( + + ) : ( + + )}
{cleanTitle(session)}
@@ -944,6 +1188,20 @@ function SessionItem({ session, isActive, isRunning, onDelete, onRename, onToggl Archive )} + {!archived && session.parent_session_id && onRemoveParent && ( + + )}
+ {/* Collapsed parent: badge the hidden direct-child count (mirrors GroupHeader). */} + {hasChildren && !expanded && childCount > 0 && ( + + {childCount} + + )} + {/* Unsent draft marker */} {hasDraft && !isActive && (