Skip to content

add conversation tree#2

Open
Broderick-Westrope wants to merge 50 commits into
devfrom
feat/tree-based-session-history
Open

add conversation tree#2
Broderick-Westrope wants to merge 50 commits into
devfrom
feat/tree-based-session-history

Conversation

@Broderick-Westrope
Copy link
Copy Markdown
Owner

No description provided.

@Broderick-Westrope Broderick-Westrope self-assigned this May 9, 2026
Chain treeParentID through the previous assistant message instead of the
user message so multi-step loop iterations form a linear chain
(U→A1→A2→A3) rather than siblings under the same user node. The sibling
structure caused ancestor-path walks to miss earlier assistant messages,
hiding their output in both the TUI and on session reload.

Also prevent the message projector from regressing leaf_id when older
messages receive metadata updates (cost, token counts) during streaming.
Only chain through lastAssistant when it is newer than lastUser (tool-call
continuation within the same turn). When lastUser is newer (new turn),
chain through the user message so it stays in the ancestor path.
Skip tool-call loop iterations (assistants whose treeParentID points to
another assistant) from the tree display. They are part of the same
logical response and not meaningful branch points. Their children still
render at the correct depth.
…response

Resolve the full continuation chain so the tree preview shows actual
output text instead of the first tool call, and selecting an assistant
navigates to the tail of the response to preserve all continuations in
the ancestor path.
…d tool calls

Move leaf_id advancement from the projector (direct DB write, invisible
to TUI sync) into updateMessage via patch(), which publishes
Session.Event.Updated so the TUI's branch filter stays current. Uses an
in-memory guard to avoid redundant patches during streaming.

Also tighten the hasToolCalls check to skip completed/errored tool parts.
Compaction tails can contain already-processed tool calls that previously
prevented the loop from exiting, causing a prefill error when the model
received messages ending with an assistant.
Use positional comparison (findLastIndex) instead of lexicographic ULID
comparison to determine if the last assistant has responded to the last
user. After compaction reordering, the compaction user (new ULID)
appears before old tail assistants (old ULIDs), making the ID comparison
unreliable. Also remove the now-redundant finish-type check since
hasToolCalls already covers the tool-calls case via status filtering.

Add unit tests for filterCompacted + break condition covering compaction
tails with continuations, completed tool calls, and ULID ordering.
The hasToolCalls filter correctly handles compaction tails (completed/errored
tools are excluded), but during live streaming there's a window where finish
is set to "tool-calls" before tool parts are materialized. Restore the
explicit finish guard alongside the new status filter.
The inArray query works without session_id since ULIDs are globally unique,
but adding the constraint follows the consistent pattern and enables the
(session_id, time_created, id) composite index.
…anchTo

A malformed or stale messageID from the client could corrupt the session's
leafID, causing getAncestorPath to return an empty path. Also log a warning
when partial summary/fromLeafID inputs are provided.
…ummary

clone() was calling MessageV2.get() per message (2 queries each). For 100
messages that's 200 synchronous queries. streamBranch() does it in 2 bulk
queries. Also log a warning when treeParentID mapping fails during clone.

Same fix applied to BranchSummary.generate() which had the same N+1 pattern.
… fix error status

- revert.ts: Change Effect.die to Effect.fail(NotFoundError) so the branch
  guard is a recoverable typed error instead of an unrecoverable defect.
- message-v2.ts: Remove redundant second DB query in getAncestorPath() —
  the rows are already fetched and ordered, use rows[rows.length - 1].
- handlers/session.ts: Return 500 instead of 400 when branch summary
  generation fails (the request was valid, the server couldn't produce a result).
- groups/session.ts: Align revert endpoint error type to ApiNotFoundError
  (consistent with the rest of the file; required by the handler fix).
… data

Session.messages() was using the flat stream() which returns all messages
across all branches. API consumers and internal callers (revert, compaction,
summary) now get branch-filtered messages matching the active leafID.
…/expand, subtree delete, branch labels

- Reverse sibling sort order (newest first) in tree view walk()
- Add filterMode 'on-demand' to DialogSelect: / activates, Esc deactivates
- Add collapse/expand with Left/Right arrow keys and ▸/▾ indicators
- Add subtree deletion with d key, confirmation prompt, ancestor guard
- Add branch labels with l key via DialogPrompt, stored on message schema
- New removeSubtree method with single-transaction batch delete
- New setLabel method with PATCH route for label persistence
- Fix pre-existing memory leak: clean up store.part on message removal
- Both Hono and HttpApi routes added with parity
Root cause: when filterActive was false, the filter input was not
focused, so nothing in the dialog captured keystrokes — they leaked
to the prompt input behind the dialog. This caused d/l keybinds to
appear non-functional and typed characters to appear in the prompt.

Fix:
- Always focus the filter input on mount (even when inactive)
- Hide cursor and text color when inactive so it appears unfocused
- Prevent all non-escape keys from reaching the input when inactive
- Gate onInput handler to reject text when filter is inactive
- Walk up treeParentID chain for left-on-collapsed parent navigation
  to handle skipped continuation assistants
Focused TUI inputs consume character keys before DialogSelect's
keybind matching in useKeyboard fires. Arrow keys (left/right) are
not consumed by inputs, which is why collapse/expand worked.

Fix: move d and l keybind logic from DialogSelect's keybind prop
into DialogTree's own useKeyboard handler, which fires regardless
of input focus state. Keep keybind entries in the prop array for
footer hint display only (no-op onTrigger).

Also:
- Add selected() to DialogSelectRef for external keybind handlers
- Revert always-focus approach (was causing blinking / cursor)
- Revert input color changes (not needed without forced focus)
- Remove catch-all preventDefault (not needed)
Opentui doesn't re-render visual tree when SolidJS signals change
inside a useKeyboard callback — the <Show when={editingLabel()}>
switch was setting the signal but not causing a visual update.

Fix: use DialogPrompt.show() for label editing and delete confirmation,
which calls dialog.replace() internally. This replaces the entire
dialog stack entry, which opentui handles correctly.

Removes: editingLabel signal, confirmDelete signal, Show nesting,
  useKeyboard for y/n confirmation
Adds: DialogPrompt.show() calls in useKeyboard for l and d keys
- After label prompt Enter/Esc or delete confirm/cancel, reopen the
  tree dialog via reopenTree() instead of leaving the sub-dialog open
- Delete keybind hint only shows in footer when the selected node is
  deletable (not on the current branch ancestor path)
- Track selected option via onMove + signal for reactive keybind hints
- Extract canDelete helper for reuse between hint and handler
- Remove debug logging imports and code
The prompt textarea's onKeyDown handler fires even when blurred in
opentui, causing up arrow to load prompt history while the tree
dialog is open. Other dialogs (Commands, etc) don't have this issue
because their filter input is always focused, capturing arrow keys.

Fix: guard onKeyDown with dialog.stack.length > 0 check, matching
the existing pattern in the blur effect at line 657.
…n-history

# Conflicts:
#	packages/opencode/src/cli/cmd/run.ts
#	packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
#	packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
#	packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
#	packages/opencode/src/config/keybinds.ts
#	packages/opencode/src/server/routes/instance/session.ts
When the continuation chain breaks (all assistant chunks get treeParentID
pointing to the user message instead of chaining), the tree view rendered
each chunk as a separate node. Now only the final assistant sibling is
shown; earlier chunks are skipped but their children are still walked to
preserve any branches.
…ith tree branching

Revert cleanup deleted messages but left leafID pointing at a removed
message, so the next user message's treeParentID referenced a ghost node
and the entire conversation history disappeared.

- cleanup now calls branchTo to reset leafID to the last surviving message
- shellImpl re-reads session after cleanup to pick up the new leafID
- Timeline dialog (<leader>g) now offers 'Branch from here' instead of
  'Revert', wiring through to branchTo for proper tree navigation
…om bar

The on-demand filter mode in DialogSelect had no activation mechanism —
pressing / did nothing because filterActive was never set to true.

Add useKeyboard handler in DialogSelect for / (activate) and Escape
(deactivate) when filterMode is on-demand. Move tree keybind hints from
the input placeholder to a new hints prop rendered in the bottom bar,
matching the action footer pattern used by other dialogs.
Enter while filtering now deactivates filter mode (keeping results)
instead of selecting the item. Escape while filtering clears the filter
and returns to normal navigation instead of closing the dialog. Uses
useBindings for Escape so it takes priority over the parent dialog's
close binding.
…ring

Escape now clears the input element's internal value so it's empty when
filter is re-activated. When filtering, tree indentation, connectors,
and fold markers are stripped — options display as a flat list with only
the branch/compaction bullet markers preserved.
selectRef.filterActive is a plain getter, not a Solid signal, so
createMemo couldn't track it. Use a filterQuery signal updated via the
onFilter callback instead. Also notify onFilter when Escape clears the
filter so titles revert to tree-prefixed form.
DialogTimeline now accepts leafID and walks the ancestor chain to show
only messages on the current branch, matching the session view behavior.
Legacy sessions without treeParentID fall back to showing all messages.
When branching from the root user message, onBranch now passes undefined
(the root's treeParentID) instead of the message's own ID. branchTo
accepts optional messageID and clears leafID to null when absent. The
conversation view and timeline distinguish tree sessions with null
leafID (show empty) from legacy sessions (show all) by checking whether
any message has treeParentID. New messages submitted after branching
before root become fresh roots with no parent, so the AI only sees the
new message.
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.

1 participant