Skip to content

Chat sidebar: nest sessions into a parent→children tree (drag-and-drop) - #305

Open
arsenmuk wants to merge 2 commits into
arsenmuk/archived-sessions-groupfrom
arsenmuk/session-hierarchy
Open

Chat sidebar: nest sessions into a parent→children tree (drag-and-drop)#305
arsenmuk wants to merge 2 commits into
arsenmuk/archived-sessions-groupfrom
arsenmuk/session-hierarchy

Conversation

@arsenmuk

@arsenmuk arsenmuk commented Aug 11, 2026

Copy link
Copy Markdown
Member

Adds a parent→children hierarchy to the chat session sidebar, built on the existing sessions.parent_session_id column (no schema change).

What you can do

  • Drag a session onto another to nest it as a child — the drop-target row highlights while hovering.
  • A parent shows an expand/collapse chevron in place of its icon; children render indented beneath it, to any depth. When collapsed it badges its hidden direct-child count (mirrors the group-header counters).
  • Nesting appears wherever the parent renders, including the pinned Starred group. Archived / System sessions stay only in their own groups.
  • Un-parent two ways: the row's ⋯ menu → "Remove from parent", or drop a nested row onto the "remove from parent" strip that appears while dragging one (native event-bubbling, no position math).
  • Expand state persists in localStorage; the active session's ancestors auto-expand so it's never hidden. Existing forks (which already set parent_session_id) nest automatically.

Server

  • PATCH /api/sessions/{id} now accepts parent_session_id (null clears it → top-level), guarded against self-parent, unknown parent, cycles, and the created fork window so a display drag can never turn a not-yet-started session into a fork.
  • The field already rides in the feed payload, so an unlimited page size renders the full tree; the client only draws what the server returns (an orphan whose parent isn't in the payload falls back to top-level).

Drag-and-drop uses native HTML5 DnD (no new dependency). Tests: tests/test_session_parent_patch.py (set / clear / self / unknown / cycle / fork-window / deep-chain); frontend tsc -b + build clean.

Stacked on #269 (its branch is this PR's base until it merges).

🤖 Generated with Claude Code

Screenshot 2026-08-11 at 15 31 10

arsenmuk and others added 2 commits August 11, 2026 12:03
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 <noreply@anthropic.com>
Mirror the GroupHeader counter — a collapsed parent row badges its direct-child
count (faint, right of the title), hidden again once expanded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds hierarchical, drag-and-drop session nesting to the chat sidebar using the existing parent relationship.

Changes:

  • Renders recursive, collapsible session trees with persisted expansion state.
  • Adds optimistic reparenting and unparenting controls.
  • Extends the PATCH endpoint with parent validation and tests.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
web/src/utils/dateGroups.ts Persists expanded parent IDs.
web/src/stores/chatStore.ts Adds optimistic parent updates.
web/src/components/Chat/SessionSidebar.tsx Implements nested rendering and drag-and-drop.
web/src/api/client.ts Extends session update typing.
tests/test_session_parent_patch.py Tests parent PATCH behavior.
nerve/gateway/routes/sessions.py Validates and saves parent relationships.
Suppressed comments (2)

nerve/gateway/routes/sessions.py:441

  • The cycle check and the update are not atomic. Concurrent requests for A→B and B→A can both read parentless rows, both pass this check, and then have their separately locked updates commit a cycle. Perform ancestor validation and the update under one database write lock/transaction, and cover the concurrent case.
            if await _would_create_cycle(deps.db, session_id, new_parent):
                raise HTTPException(status_code=400, detail="That drop would create a cycle")

web/src/components/Chat/SessionSidebar.tsx:1065

  • HTML5 draggable provides no keyboard operation for assigning a parent, so keyboard-only users cannot use the core nesting feature. Add an accessible non-drag action such as “Move under…” in the row menu, or equivalent keyboard controls with announced grab/drop state.
      draggable={draggable || undefined}
      onDragStart={draggable && dnd ? (e) => {
        // Override the browser's default <a href> drag with our session id.
        e.dataTransfer.setData('text/plain', session.id);
        e.dataTransfer.effectAllowed = 'move';
        dnd.onDragStart(session.id);
      } : undefined}

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +425 to +441
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")
Comment on lines +401 to +405
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;
Comment on lines +865 to +868
set(s => ({
sessions: s.sessions.map(sess =>
sess.id === childId ? { ...sess, parent_session_id: prev } : sess
),
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants