feat: fork conversation from any point in AI message history#744
feat: fork conversation from any point in AI message history#744chr1syy wants to merge 2 commits intoRunMaestro:rcfrom
Conversation
…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>
📝 WalkthroughWalkthroughThis PR adds fork-conversation functionality, enabling users to branch existing conversations at any point in the AI message history. A new Changes
Sequence DiagramsequenceDiagram
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
Estimated Code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
Greptile SummaryThis 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.
Confidence Score: 4/5Safe 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
Sequence DiagramsequenceDiagram
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
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); |
There was a problem hiding this comment.
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).
| .map((log) => { | ||
| const role = log.source === 'user' ? 'User' : 'Assistant'; | ||
| return `${role}: ${log.text}`; | ||
| }) | ||
| .join('\n\n'); |
There was a problem hiding this comment.
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:
| .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}`; |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
src/renderer/hooks/agent/useForkConversation.ts (1)
12-17: Keep this callback stable.Capturing
sessionshere means every streamed log update recreateshandleForkConversation. 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
📒 Files selected for processing (8)
src/renderer/App.tsxsrc/renderer/components/MainPanel/MainPanel.tsxsrc/renderer/components/MainPanel/MainPanelContent.tsxsrc/renderer/components/MainPanel/types.tssrc/renderer/components/TerminalOutput.tsxsrc/renderer/hooks/agent/index.tssrc/renderer/hooks/agent/useForkConversation.tssrc/renderer/hooks/props/useMainPanelProps.ts
| {/* 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" |
There was a problem hiding this comment.
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.
| 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'); |
There was a problem hiding this comment.
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.
| 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], |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
Summary
Closes #205
cwdandfullPathfrom source session so the forked agent spawns in the correct working directoryImplementation
useForkConversationhook following the established Send-to-Agent spawn patternTest plan
🤖 Generated with Claude Code
Summary by CodeRabbit