Skip to content

feat: fork conversation from any point in AI message history#744

Open
chr1syy wants to merge 2 commits intoRunMaestro:rcfrom
chr1syy:feat/fork-conversation
Open

feat: fork conversation from any point in AI message history#744
chr1syy wants to merge 2 commits intoRunMaestro:rcfrom
chr1syy:feat/fork-conversation

Conversation

@chr1syy
Copy link
Copy Markdown
Contributor

@chr1syy chr1syy commented Apr 7, 2026

Summary

Closes #205

  • Adds a "Fork conversation" button (GitFork icon) on hover for user and AI messages in the AI terminal
  • Clicking forks the conversation up to that message into a new agent session, carrying full conversation context
  • The new session inherits all source config (custom path/args/env, SSH remote, model, context window, group)
  • Copies cwd and fullPath from source session so the forked agent spawns in the correct working directory

Implementation

  • New useForkConversation hook following the established Send-to-Agent spawn pattern
  • Props threaded through MainPanel → MainPanelContent → TerminalOutput → LogItem
  • Fork button visibility gated to AI mode, user/ai source messages only
  • System prompt and template variables applied to the spawned agent
  • Error handling with Sentry reporting and state cleanup on spawn failure

Test plan

  • Existing TerminalOutput tests pass (97/97)
  • TypeScript type check clean
  • Manual: hover over AI/user message, click fork icon, verify new session created with context
  • Manual: fork from agent with custom cwd, verify forked agent spawns in same directory
  • Manual: fork from SSH remote session, verify SSH config propagated

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added fork conversation feature - click the fork button on any message to create a new conversation branching from that point. The new conversation includes all context up to the selected message.

chr1syy and others added 2 commits April 7, 2026 09:14
…tory

Add "Fork conversation from here" action to AI response and user messages
in the chat view. Clicking the GitFork icon creates a new session with
conversation history truncated at the selected message, formats context,
and auto-spawns the agent with the forked context.

Follows the existing "Send Context to Agent" pattern from
useMergeTransferHandlers.ts. Creates a new session via createMergedSession()
with source session config (custom model, SSH, env vars) carried over.

Files changed:
- New: src/renderer/hooks/agent/useForkConversation.ts (hook)
- Modified: TerminalOutput.tsx (fork button on ai/user messages)
- Modified: MainPanel types/content/component (prop threading)
- Modified: useMainPanelProps.ts (deps wiring)
- Modified: App.tsx (hook instantiation and dep passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Without this, forked sessions would default to projectRoot even if the
source agent had navigated to a subdirectory, causing a mismatch between
the new session's displayed path and the actual spawn directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

This PR adds fork-conversation functionality, enabling users to branch existing conversations at any point in the AI message history. A new useForkConversation hook handles session creation, conversation context preparation, agent invocation, and error handling. The callback is threaded through the component hierarchy to display fork buttons on AI messages.

Changes

Cohort / File(s) Summary
Fork Conversation Hook
src/renderer/hooks/agent/useForkConversation.ts, src/renderer/hooks/agent/index.ts
Implements core fork logic: slices conversation history up to the target message, filters and formats logs into context, creates a new merged session seeded with fork metadata, spawns the agent asynchronously (handling SSH flags, git status, system prompts), shows success toast with token estimate, and captures spawn errors with proper state rollback.
Component Props Propagation
src/renderer/components/MainPanel/types.ts, src/renderer/components/MainPanel/MainPanel.tsx, src/renderer/components/MainPanel/MainPanelContent.tsx
Threads onForkConversation callback through component hierarchy from MainPanel down to MainPanelContent as optional prop, enabling child components to access the fork handler.
Terminal Output UI & Callback Wiring
src/renderer/components/TerminalOutput.tsx
Extends LogItemProps and TerminalOutputProps with onForkConversation callback. Renders a GitFork button conditionally (AI mode, 'ai' or 'user' source) on log items. Updates memoization check to include callback reference changes.
App Integration & Props Wiring
src/renderer/App.tsx, src/renderer/hooks/props/useMainPanelProps.ts
Instantiates useForkConversation hook in MaestroConsoleInner, wires resulting handleForkConversation into MainPanel props. Updates UseMainPanelPropsDeps to include fork handler and includes it in the memoized props object.

Sequence Diagram

sequenceDiagram
    actor User
    participant TerminalOutput
    participant App
    participant useForkConversation
    participant SessionManager
    participant AgentService
    participant Toast

    User->>TerminalOutput: Click fork button on AI message
    TerminalOutput->>App: onForkConversation(logIndex)
    App->>useForkConversation: Call fork handler with logIndex
    useForkConversation->>SessionManager: Slice history logs up to logIndex
    SessionManager-->>useForkConversation: Filtered logs (user/assistant/stdout)
    useForkConversation->>SessionManager: createMergedSession with fork context
    SessionManager-->>useForkConversation: New session created
    useForkConversation->>AgentService: Load agent definition & determine flags
    AgentService->>AgentService: Query git status (with error handling)
    AgentService->>AgentService: Template maestroSystemPrompt if needed
    AgentService-->>useForkConversation: Prepared fork prompt & spawn options
    useForkConversation->>AgentService: window.maestro.process.spawn(...)
    alt Spawn Success
        AgentService-->>useForkConversation: Agent spawned
        useForkConversation->>SessionManager: Update sessions state, mark tab busy
        useForkConversation->>Toast: Show success + token estimate
    else Spawn Failure
        AgentService-->>useForkConversation: Spawn error
        useForkConversation->>SessionManager: Append error log, set tab to idle
    end
    SessionManager-->>App: State updated
    App->>TerminalOutput: Re-render with new session state
Loading

Estimated Code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 A fork in conversation's road,
Branch off from history's laden load,
Retry, explore, begin anew,
Each fork spawns paths both fresh and true,
Adventure waits for me and you! ⑂🌳

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: fork conversation from any point in AI message history' clearly and specifically summarizes the main feature addition in the changeset.
Linked Issues check ✅ Passed The implementation fully satisfies #205: fork icon added for user/AI messages, creates new independent sessions with conversation history, inherits source session config, applies system prompts, and includes proper error handling.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the fork conversation feature and its required prop threading through the component hierarchy; no extraneous modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 7, 2026

Greptile Summary

This PR adds a "Fork conversation" button to user/AI messages in the AI terminal, spawning a new agent session pre-loaded with the conversation history up to that point. The implementation follows the established Send-to-Agent spawn pattern, correctly threads config from the source session (custom path/args/env, SSH, model, group), and includes proper Sentry error reporting with state cleanup on failure.

  • P1: The logIndex passed from onForkConversation(index) is the iteration index of filteredLogs — a search-filtered and collapsed view — not the position in sourceTab.logs. When output-search is active or consecutive AI responses are collapsed, sourceTab.logs.slice(0, logIndex + 1) cuts the conversation at the wrong message. The fix is to pass log.id at the call-site and resolve the actual index inside useForkConversation.

Confidence Score: 4/5

Safe to merge after fixing the logIndex/filteredLogs mismatch, which causes incorrect context slicing when search is active or AI responses are collapsed.

One P1 defect (wrong slice index in the fork hook) that causes incorrect conversation context in foreseeable real-world conditions; all other findings are P2.

src/renderer/hooks/agent/useForkConversation.ts (logIndex vs sourceTab.logs mismatch) and src/renderer/components/TerminalOutput.tsx (call-site needs to pass log.id instead of visual index)

Important Files Changed

Filename Overview
src/renderer/hooks/agent/useForkConversation.ts New fork hook — P1 logIndex/filteredLogs mismatch causes incorrect log slicing; stdout role labeling is a P2 semantic issue
src/renderer/components/TerminalOutput.tsx Fork button wired correctly to LogItemComponent; call-site passes visual map index instead of log.id, contributing to the P1 index bug
src/renderer/App.tsx Clean integration: useForkConversation hooked up with sessions, setSessions, activeSessionId, and setActiveSessionId
src/renderer/hooks/props/useMainPanelProps.ts handleForkConversation correctly threaded into MainPanel props and memoization dependency array
src/renderer/components/MainPanel/types.ts onForkConversation prop declaration added correctly to MainPanelProps interface
src/renderer/components/MainPanel/MainPanel.tsx onForkConversation passed through to MainPanelContent without issues
src/renderer/components/MainPanel/MainPanelContent.tsx onForkConversation forwarded to TerminalOutput cleanly
src/renderer/hooks/agent/index.ts useForkConversation correctly exported from the agent hooks barrel

Sequence Diagram

sequenceDiagram
    participant User
    participant TerminalOutput
    participant useForkConversation
    participant createMergedSession
    participant window.maestro.process

    User->>TerminalOutput: Hover + click GitFork button (on ai/user message)
    TerminalOutput->>useForkConversation: onForkConversation(filteredIndex)
    Note over useForkConversation: Looks up active session & tab
    useForkConversation->>useForkConversation: sourceTab.logs.slice(0, logIndex+1)
    Note over useForkConversation: ⚠️ filteredIndex ≠ sourceTab index when search active
    useForkConversation->>useForkConversation: Format logs as context message
    useForkConversation->>createMergedSession: Create new session (forkName, projectRoot, toolType)
    createMergedSession-->>useForkConversation: { session: newSession, tabId: newTabId }
    useForkConversation->>useForkConversation: Copy source config (cwd, customPath, SSH, model…)
    useForkConversation->>useForkConversation: setSessions([...prev, newSession])
    useForkConversation->>useForkConversation: setActiveSessionId(newSession.id)
    useForkConversation->>window.maestro.process: spawn({ sessionId, toolType, cwd, prompt, … })
    window.maestro.process-->>User: New forked agent session appears
Loading

Reviews (1): Last reviewed commit: "fix: copy cwd and fullPath from source s..." | Re-trigger Greptile

if (!sourceTab) return;

// 1. Slice logs up to and including the selected message
const slicedLogs = sourceTab.logs.slice(0, logIndex + 1);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Fork slices original logs using the filtered/collapsed visual index

The logIndex received here is the map-iteration index from filteredLogs in TerminalOutput — a search-filtered and consecutively-collapsed view of the raw tab logs. When an output-search query is active, or when consecutive AI-response entries have been collapsed into one visual row, filteredLogs is shorter than sourceTab.logs, so slice(0, logIndex + 1) silently cuts the conversation at the wrong point.

The cleanest fix is to have the call-site pass the log's id (available as log.id in LogItemComponent) and resolve the actual position inside the hook. This requires changing the signature from (logIndex: number) to (logId: string), updating the hook body, and updating the call-site in TerminalOutput.tsx from onForkConversation(index) to onForkConversation(log.id).

Comment on lines +38 to +42
.map((log) => {
const role = log.source === 'user' ? 'User' : 'Assistant';
return `${role}: ${log.text}`;
})
.join('\n\n');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 stdout entries labeled as Assistant in forked context

stdout logs represent raw shell/tool output, not AI-generated text. Labeling them "Assistant: <shell output>" may mislead the forked agent into thinking the content is its own prior response. Consider using a distinct role label:

Suggested change
.map((log) => {
const role = log.source === 'user' ? 'User' : 'Assistant';
return `${role}: ${log.text}`;
})
.join('\n\n');
const role =
log.source === 'user' ? 'User' : log.source === 'stdout' ? 'Tool Output' : 'Assistant';
return `${role}: ${log.text}`;

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/renderer/hooks/agent/useForkConversation.ts (1)

12-17: Keep this callback stable.

Capturing sessions here means every streamed log update recreates handleForkConversation. That new function is threaded into memoized transcript items, so streaming a response now invalidates memoization and re-renders the whole visible log list. Reading current state from a ref/store getter would avoid that churn.

Also applies to: 235-235

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/agent/useForkConversation.ts` around lines 12 - 17, The
callback handleForkConversation inside useForkConversation is unstable because
it closes over the sessions array, causing it to be recreated on every streamed
log update; change it to not capture sessions directly by using a stable
ref/getter for current sessions (e.g., sessionsRef.current or a store getter)
and wrap handleForkConversation in useCallback with minimal deps, then update
state via the functional updater form of setSessions and setActiveSessionId as
needed; ensure you only reference stable symbols (useForkConversation,
handleForkConversation, sessionsRef/current getter, setSessions functional
updater, setActiveSessionId) so memoized transcript items no longer re-render on
stream updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/renderer/components/TerminalOutput.tsx`:
- Around line 920-924: The fork button currently passes the rendered list index
(index from filteredLogs) to onForkConversation, which causes slicing of
sourceTab.logs at the wrong position; instead pass a raw log boundary that
identifies the original message in sourceTab.logs (e.g., pass the log's unique
id or compute its index in sourceTab.logs and pass that), or simply pass the log
object itself and let useForkConversation locate the correct index by matching
id/timestamp; update the button's onClick from onForkConversation(index) to
onForkConversation(logId || originalIndex || log) and adjust useForkConversation
to resolve the true sourceTab.logs index before slicing.

In `@src/renderer/hooks/agent/useForkConversation.ts`:
- Around line 31-42: The fork serializer currently drops image-backed turns by
only using log.text and hardcoding hasImages: false; update
useForkConversation's formatting logic (see formattedContext and slicedLogs) to
preserve attachment data by including each log's attachments/hasImages metadata
when present (e.g., carry attachments array or a flag alongside the role/text),
and compute a derived hasImages = slicedLogs.some(l => l.attachments?.length >
0) to pass into the spawn/fork call instead of false; also mirror the same
preservation for the other serializer path referenced around spawn (the block
that currently sets hasImages: false) so the forked conversation receives the
original attachments and image flag.
- Around line 80-96: createMergedSession() seeds the fork with projectRoot-based
shellCwd and an initial terminal tab at projectRoot, but the code only copies
session.cwd and session.fullPath into newSession; update the fork to preserve
the source working dir by also copying the shell/terminal working directory:
assign newSession.shellCwd = session.shellCwd (or session.cwd if shellCwd is
undefined) and update the initial terminal tab's working dir (the tab object
returned in newSession.aiTabs[0] or the initial terminal tab structure) to the
source session's cwd/fullPath so the forked terminal opens in the same
subdirectory.
- Around line 73-84: The new fork is being seeded with a single synthetic entry
(userContextLog) so the forked tab loses the original turns; change the
createMergedSession call so mergedLogs contains the actual sliced conversation
entries (the array holding the original turns up to the fork point) instead of
just userContextLog — e.g., use mergedLogs: [forkNotice, ...slicedLogs] (or the
existing variable name that holds the sliced logs), optionally appending a
context message if needed, and remove/replace userContextLog accordingly so
replay/delete/inspection operate on the real copied history.

---

Nitpick comments:
In `@src/renderer/hooks/agent/useForkConversation.ts`:
- Around line 12-17: The callback handleForkConversation inside
useForkConversation is unstable because it closes over the sessions array,
causing it to be recreated on every streamed log update; change it to not
capture sessions directly by using a stable ref/getter for current sessions
(e.g., sessionsRef.current or a store getter) and wrap handleForkConversation in
useCallback with minimal deps, then update state via the functional updater form
of setSessions and setActiveSessionId as needed; ensure you only reference
stable symbols (useForkConversation, handleForkConversation, sessionsRef/current
getter, setSessions functional updater, setActiveSessionId) so memoized
transcript items no longer re-render on stream updates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 21d99e96-395f-4419-8af5-b8096804238a

📥 Commits

Reviewing files that changed from the base of the PR and between 796343e and 29b0d9e.

📒 Files selected for processing (8)
  • src/renderer/App.tsx
  • src/renderer/components/MainPanel/MainPanel.tsx
  • src/renderer/components/MainPanel/MainPanelContent.tsx
  • src/renderer/components/MainPanel/types.ts
  • src/renderer/components/TerminalOutput.tsx
  • src/renderer/hooks/agent/index.ts
  • src/renderer/hooks/agent/useForkConversation.ts
  • src/renderer/hooks/props/useMainPanelProps.ts

Comment on lines +920 to +924
{/* Fork conversation from this message - AI and user messages only */}
{(log.source === 'ai' || log.source === 'user') && isAIMode && onForkConversation && (
<button
onClick={() => onForkConversation(index)}
className="p-1.5 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Pass a raw log boundary here, not the rendered list index.

index is the position in filteredLogs, which is both search-filtered and collapsed. useForkConversation then slices sourceTab.logs with that number, so a multi-chunk AI reply or active output search will fork from the wrong point and can drop part of the conversation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/TerminalOutput.tsx` around lines 920 - 924, The fork
button currently passes the rendered list index (index from filteredLogs) to
onForkConversation, which causes slicing of sourceTab.logs at the wrong
position; instead pass a raw log boundary that identifies the original message
in sourceTab.logs (e.g., pass the log's unique id or compute its index in
sourceTab.logs and pass that), or simply pass the log object itself and let
useForkConversation locate the correct index by matching id/timestamp; update
the button's onClick from onForkConversation(index) to onForkConversation(logId
|| originalIndex || log) and adjust useForkConversation to resolve the true
sourceTab.logs index before slicing.

Comment on lines +31 to +42
const formattedContext = slicedLogs
.filter(
(log) =>
log.text &&
log.text.trim() &&
(log.source === 'user' || log.source === 'ai' || log.source === 'stdout')
)
.map((log) => {
const role = log.source === 'user' ? 'User' : 'Assistant';
return `${role}: ${log.text}`;
})
.join('\n\n');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Image-backed turns are dropped from the fork context.

The serializer only keeps log.text, and the spawn path hardcodes hasImages: false. Forking after a screenshot/image prompt will therefore branch without the attachments that the source conversation depended on.

Also applies to: 142-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/agent/useForkConversation.ts` around lines 31 - 42, The
fork serializer currently drops image-backed turns by only using log.text and
hardcoding hasImages: false; update useForkConversation's formatting logic (see
formattedContext and slicedLogs) to preserve attachment data by including each
log's attachments/hasImages metadata when present (e.g., carry attachments array
or a flag alongside the role/text), and compute a derived hasImages =
slicedLogs.some(l => l.attachments?.length > 0) to pass into the spawn/fork call
instead of false; also mirror the same preservation for the other serializer
path referenced around spawn (the block that currently sets hasImages: false) so
the forked conversation receives the original attachments and image flag.

Comment on lines +73 to +84
const userContextLog: LogEntry = {
id: `fork-context-${Date.now()}`,
timestamp: Date.now(),
source: 'user',
text: contextMessage,
};

const { session: newSession, tabId: newTabId } = createMergedSession({
name: forkName,
projectRoot: session.projectRoot,
toolType: session.toolType,
mergedLogs: [forkNotice, userContextLog],
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Seed the fork with the actual sliced logs, not one synthetic context message.

mergedLogs: [forkNotice, userContextLog] collapses the branch into a single synthetic user entry, so the new tab no longer contains the original turns up to the fork point. That breaks the expected “forked history” UX because replay/delete/inspection now operate on a summary instead of the copied conversation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/agent/useForkConversation.ts` around lines 73 - 84, The
new fork is being seeded with a single synthetic entry (userContextLog) so the
forked tab loses the original turns; change the createMergedSession call so
mergedLogs contains the actual sliced conversation entries (the array holding
the original turns up to the fork point) instead of just userContextLog — e.g.,
use mergedLogs: [forkNotice, ...slicedLogs] (or the existing variable name that
holds the sliced logs), optionally appending a context message if needed, and
remove/replace userContextLog accordingly so replay/delete/inspection operate on
the real copied history.

Comment on lines +80 to +96
const { session: newSession, tabId: newTabId } = createMergedSession({
name: forkName,
projectRoot: session.projectRoot,
toolType: session.toolType,
mergedLogs: [forkNotice, userContextLog],
saveToHistory: true,
});

// 5. Mark the new tab as busy (we're about to spawn)
const newTab = newSession.aiTabs[0];
newTab.state = 'busy';
newTab.thinkingStartTime = Date.now();
newTab.awaitingSessionId = true;

// Copy relevant session config from source
newSession.cwd = session.cwd;
newSession.fullPath = session.fullPath;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The fork still opens its terminal at projectRoot.

createMergedSession() initializes shellCwd and the initial terminal tab with projectRoot; this block only patches cwd/fullPath afterwards. If the source session is currently in a subdirectory, switching the forked session to terminal mode drops the user back at the repo root instead of the source cwd.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/hooks/agent/useForkConversation.ts` around lines 80 - 96,
createMergedSession() seeds the fork with projectRoot-based shellCwd and an
initial terminal tab at projectRoot, but the code only copies session.cwd and
session.fullPath into newSession; update the fork to preserve the source working
dir by also copying the shell/terminal working directory: assign
newSession.shellCwd = session.shellCwd (or session.cwd if shellCwd is undefined)
and update the initial terminal tab's working dir (the tab object returned in
newSession.aiTabs[0] or the initial terminal tab structure) to the source
session's cwd/fullPath so the forked terminal opens in the same subdirectory.

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