Skip to content

fix(fork): show Fork button on AI messages and correct role mapping#857

Merged
pedramamini merged 4 commits intoRunMaestro:rcfrom
chr1syy:feat/fork-ai-message
Apr 21, 2026
Merged

fix(fork): show Fork button on AI messages and correct role mapping#857
pedramamini merged 4 commits intoRunMaestro:rcfrom
chr1syy:feat/fork-ai-message

Conversation

@chr1syy
Copy link
Copy Markdown
Contributor

@chr1syy chr1syy commented Apr 18, 2026

Summary

  • UI: Fork button (GitFork icon) now appears on AI response messages in the chat, not just user messages. The existing condition checked source === 'ai', but AI response text is actually stored with source: 'stdout' (see useBatchedSessionUpdates.ts:223), so the button never rendered on AI messages.
  • Hook: In the forked-context prompt, AI responses (source 'stdout') were being labeled "Tool Output:" instead of "Assistant:", which confused the downstream agent. Only source === 'tool' is now labeled Tool Output.

Repro (before fix)

Hover an AI response in AI mode → action buttons show: Markdown toggle, Copy, Save, Publish to Gist. The Fork button is missing. User report confirmed via screenshot.

Changes

  • src/renderer/components/TerminalOutput.tsx — broaden the Fork button condition to 'user' | 'ai' | 'stdout', still gated by isAIMode so terminal-mode stdout is excluded.
  • src/renderer/hooks/agent/useForkConversation.ts — role mapping: user → User, tool → Tool Output, everything else in the filtered set (ai, stdout) → Assistant. Added a comment pointing to useBatchedSessionUpdates as the source-of-truth for why stdout is the AI's output in AI mode.

Test plan

  • Open an AI tab, send a prompt, wait for response.
  • Hover the AI response — GitFork icon appears alongside Markdown/Copy/Save/Gist.
  • Click Fork on an AI response → new forked session spawns; forked-context message labels prior AI turns as Assistant: (not Tool Output:).
  • Fork from a user message still works as before.
  • Shell-terminal (non-AI) stdout does not show the Fork button.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • "Fork conversation" now applies to stdout entries and preserves full merged AI response blocks when forking.
  • New Features

    • Forking creates a new AI tab inside the current session (inserted after the source tab) and activates it; spawn and toast behavior updated to target the existing session/tab.
  • Tests

    • Added comprehensive tests for fork behavior, multi-chunk responses, activation/state changes, and error handling.
  • Documentation

    • Updated in-code docs/comments to reflect expanded fork eligibility.

… mapping

Fork button never appeared on AI responses because the UI condition
checked `source === 'ai'`, but AI response text is actually stored with
`source: 'stdout'` (see useBatchedSessionUpdates.ts). Extend the
TerminalOutput condition to accept 'user' | 'ai' | 'stdout' (still
gated by `isAIMode`, which excludes shell terminal stdout).

Also fix the forked-context role mapping in useForkConversation: in AI
mode, `stdout` IS the AI response, not tool output, so map stdout →
Assistant. Only `source === 'tool'` remains labeled Tool Output.
Without this, AI responses in the forked prompt were mislabeled as
"Tool Output: ...", misleading the downstream agent.

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

coderabbitai Bot commented Apr 18, 2026

📝 Walkthrough

Walkthrough

Fork conversation now supports stdout logs in addition to user/ai; the fork context extends through contiguous assistant/tool chunks, and forking creates a new AI tab inside the existing session (spawn bound to that session) with updated role mapping and error rollback.

Changes

Cohort / File(s) Summary
Terminal output UI
src/renderer/components/TerminalOutput.tsx
"Fork conversation" button now renders for log.source values user, ai, and stdout; update onForkConversation prop/type comment to include stdout.
Fork logic & tab spawn
src/renderer/hooks/agent/useForkConversation.ts
Extend fork endIndex across collapsed assistant/tool chunks so full AI/stdout responses are included; map toolTool Output and other non-user entries (including stdout) → Assistant; create an AI tab within the existing session via createTabAtPosition and update session/tab state, spawn agent using ${session.id}-ai-${newTab.id}, show toast tied to existing session/tab, and implement error handling/rollback.
Tests for fork behavior
src/__tests__/renderer/hooks/useForkConversation.test.ts
Add Vitest suite validating new-tab-in-session behavior, multi-chunk context slicing, spawn payload/sessionId, busy→idle transitions, toast binding, and spawn-error rollback with system error log.
App hook usage
src/renderer/App.tsx
Update useForkConversation call to 3-arg form (sessions, setSessions, activeSessionId) and adjust comment to reflect tab-in-session fork behavior.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Client UI
    participant Store as SessionStore
    participant Spawner as AgentSpawner
    participant Notif as NotificationService
    participant Logger as Sentry

    UI->>Store: request fork(logId)
    Store->>Store: locate active session & tab, compute endIndex (extend through assistant/tool/stdout chunks)
    Store->>Store: createTabAtPosition(new AI tab), update sessions & set active tab, set inputMode='ai'
    Store->>Spawner: spawn agent with sessionId `${session.id}-ai-${newTab.id}` and payload (forked user-context)
    Spawner-->>Store: spawn success (pid) -> stream chunks appended to new tab logs
    Store->>Notif: show "Conversation Forked" toast referencing session.id and newTab.id
    Note over Spawner,Store: on spawn error -> Logger.captureException, set tab state idle, append system error log
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels

ready to merge

Poem

🐇 I found a stray stdout in the glen,
I nudged it into conversations again.
Tabs sprout like clover, AI hums a song,
Forks pick up pieces and carry them along.
Hop on — this rabbit's stitched the thread along.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the two main changes: expanding Fork button visibility to AI messages (stdout) and correcting role mapping for forked context prompts.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 18, 2026

Greptile Summary

This PR fixes two related bugs in the Fork conversation feature: the Fork button was never visible on AI responses because source === 'ai' never matches in practice (AI output is stored as source: 'stdout'), and the role mapping incorrectly labeled stdout entries as "Tool Output:" instead of "Assistant:". Both fixes are correct.

  • Incomplete fork context for multi-chunk responses: useForkConversation resolves the clicked log's position using findIndex on the raw logs, then slices up to that index. The collapsed UI entry uses the first raw stdout chunk's ID, so slicedLogs is cut off at that first chunk. When useBatchedSessionUpdates creates multiple stdout entries (chunks > 500 ms apart), all chunks after the first are dropped from the forked context, producing a truncated "Assistant:" turn.

Confidence Score: 4/5

Mergeable after addressing the incomplete-context bug in useForkConversation — the UI fix is correct but the slice logic needs to extend through all consecutive stdout/ai chunks for the clicked response.

One P1 bug: forking from a collapsed AI response that spans multiple raw stdout entries produces a truncated fork context because slicing stops at the first chunk's index. This is directly triggered by the newly-visible Fork button on AI messages — the code path didn't exist before this PR. The role mapping fix and UI condition change are both correct.

src/renderer/hooks/agent/useForkConversation.ts — the slice boundary logic at lines 25-29

Important Files Changed

Filename Overview
src/renderer/hooks/agent/useForkConversation.ts Role mapping corrected (stdout → Assistant, tool → Tool Output); filter expanded to include stdout. However, slicing to rawLogIndex+1 cuts off all chunks after the first raw stdout entry when the AI response spans multiple entries, producing an incomplete fork context.
src/renderer/components/TerminalOutput.tsx Fork button condition correctly extended to include stdout source; isAIMode gate prevents it from appearing in terminal mode. One stale comment in the LogItemProps interface.

Comments Outside Diff (2)

  1. src/renderer/hooks/agent/useForkConversation.ts, line 25-29 (link)

    P1 Incomplete context when forking from a multi-chunk AI response

    When a user forks from a collapsed AI response, slicedLogs is cut off at the first raw stdout entry, missing all subsequent chunks that make up the same response.

    useBatchedSessionUpdates.ts (lines 225-246) uses a 500 ms time window for grouping: chunks that arrive more than 500 ms apart are stored as separate stdout entries. collapsedLogs in TerminalOutput then combines those entries into one display block, using currentResponseGroup[0].id as the collapsed entry's ID. When Fork is clicked, onForkConversation(log.id) passes that first-chunk ID. Here, findIndex resolves it to the first raw entry, and slice(0, rawLogIndex + 1) includes only that first chunk — the rest of the AI response is silently dropped from the forked context.

    The fix is to extend the slice through all consecutive stdout/ai entries that form the same response:

    // Find last entry in the collapsed AI-response group
    let endIndex = rawLogIndex;
    const clickedSource = sourceTab.logs[rawLogIndex]?.source;
    if (clickedSource === 'stdout' || clickedSource === 'ai') {
        while (
            endIndex + 1 < sourceTab.logs.length &&
            (sourceTab.logs[endIndex + 1].source === 'stdout' ||
                sourceTab.logs[endIndex + 1].source === 'ai')
        ) {
            endIndex++;
        }
    }
    const slicedLogs = sourceTab.logs.slice(0, endIndex + 1);
  2. src/renderer/components/TerminalOutput.tsx, line 178-179 (link)

    P2 Stale prop comment doesn't mention stdout

    The JSDoc comment on the onForkConversation prop still says "user and ai source messages" but the Fork button now also renders for stdout entries.

Reviews (1): Last reviewed commit: "fix: show Fork button on AI messages (st..." | Re-trigger Greptile

chr1syy and others added 2 commits April 18, 2026 21:32
Addresses PR RunMaestro#857 review:

P1 (greptile): When an AI response spans multiple raw stdout entries
(chunks arriving >500ms apart, per useBatchedSessionUpdates), the UI
collapses them into one visual block keyed by the FIRST entry's id.
The fork click passed that first-chunk id, so slice(0, rawLogIndex+1)
truncated the forked context to just the opening chunk of the response,
silently dropping the rest.

Fix: after resolving rawLogIndex, walk forward through consecutive
entries that are NOT user/tool/thinking (mirroring collapsedLogs'
grouping rule in TerminalOutput) and extend endIndex to the last chunk
of the same block. Slice uses endIndex+1. User messages and tool/
thinking entries are not collapsed in the UI, so endIndex stays put
for those — preserving existing behavior when forking from a user
turn or a tool/thinking log.

P2 (greptile): Update stale JSDoc on onForkConversation prop in
TerminalOutput.tsx to reflect that the button also renders for
source='stdout' (AI responses in AI mode), not just 'user' and 'ai'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… agent

Fork Conversation now adds an AI tab to the current session (via
createTabAtPosition) and reuses session.id for the agent spawn, matching
user expectations. Previously it created a brand-new session, which
surfaced in the Left Bar as a separate agent.

Adds a 10-case test suite locking in the contract: same-session
invariant, tab inserted after source, busy/active/order state, multi-
chunk AI response capture, early-return guards, and spawn-failure
rollback.
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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/renderer/hooks/agent/useForkConversation.ts (1)

227-246: ⚠️ Potential issue | 🟠 Major

Don’t mark the whole session idle if another tab is still busy.

Now that fork creates a tab in the existing session, this failure path can clear session.state, busySource, and thinkingStartTime even when another AI tab in the same session is still running.

🐛 Proposed fix
 					setSessions((prev) =>
 						prev.map((s) => {
 							if (s.id !== session.id) return s;
+							const aiTabs = s.aiTabs.map((tab) =>
+								tab.id === newTabId
+									? {
+											...tab,
+											state: 'idle' as const,
+											thinkingStartTime: undefined,
+											awaitingSessionId: false,
+											logs: [...tab.logs, errorLog],
+										}
+									: tab
+							);
+							const hasOtherBusyAiTab = aiTabs.some(
+								(tab) => tab.id !== newTabId && tab.state === 'busy'
+							);
 							return {
 								...s,
-								state: 'idle',
-								busySource: undefined,
-								thinkingStartTime: undefined,
-								aiTabs: s.aiTabs.map((tab) =>
-									tab.id === newTabId
-										? {
-												...tab,
-												state: 'idle' as const,
-												thinkingStartTime: undefined,
-												awaitingSessionId: false,
-												logs: [...tab.logs, errorLog],
-											}
-										: tab
-								),
+								state: hasOtherBusyAiTab ? 'busy' : 'idle',
+								busySource: hasOtherBusyAiTab ? 'ai' : undefined,
+								thinkingStartTime: hasOtherBusyAiTab ? s.thinkingStartTime : undefined,
+								aiTabs,
 							};
 						})
 					);
🤖 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 227 - 246, The
current failure branch in the setSessions callback unconditionally sets the
entire session (session.state, busySource, thinkingStartTime) to 'idle' even
though other AI tabs in the same session may still be busy; update the map
callback in setSessions so that after updating the specific tab (tab.id ===
newTabId) you compute whether any aiTabs (including the newly-updated one) still
have a non-idle busy state and only set session.state to 'idle' / clear
busySource and thinkingStartTime when no other aiTabs remain busy; keep the
existing per-tab update (setting that tab to idle, clearing its
thinkingStartTime/awaitingSessionId, and appending errorLog) but derive
session-level fields from s.aiTabs.some(tab => tab.state !== 'idle') (or
equivalent) so you don't clear session-level busy flags while another tab is
running.
🧹 Nitpick comments (1)
src/__tests__/renderer/hooks/useForkConversation.test.ts (1)

144-172: Add a unified-order assertion for non-tail source tabs.

This test verifies aiTabs placement but would still pass if unifiedTabOrder appends the forked tab after tab-tail, causing the visible unified tab strip to be ordered incorrectly.

🧪 Proposed test assertion
 			const newTab = aiTabs[2];
 			expect(newTab.id).not.toBe(sourceTab.id);
 			expect(newTab.name).toBe('Forked: Source');
+			expect(getSessions()[0].unifiedTabOrder).toEqual([
+				{ type: 'ai', id: firstTab.id },
+				{ type: 'ai', id: sourceTab.id },
+				{ type: 'ai', id: newTab.id },
+				{ type: 'ai', id: tailTab.id },
+			]);
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/renderer/hooks/useForkConversation.test.ts` around lines 144 -
172, Test is missing a check that the fork also updates unifiedTabOrder so the
new entry appears immediately after the source tab (not appended after tail);
after calling fork('log-s') use getSessions() to inspect unifiedTabOrder on the
session and assert that there is a new { type: 'ai', id: <newTab.id> } entry
directly after the entry whose id equals sourceTab.id and before the entry with
id equal to tailTab.id; update the test surrounding the fork/getSessions calls
(ref: fork, getSessions, unifiedTabOrder, sourceTab.id, tailTab.id) to include
this assertion.
🤖 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/hooks/agent/useForkConversation.ts`:
- Around line 123-148: The current logic appends the new AI tab ref to
unifiedTabOrder (updatedOrder = [...updatedOrder, { type: 'ai', id: newTabId }])
which places the fork at the end rather than immediately after the source;
change the insertion to locate the source entry in unifiedTabOrder (find index
where entry.type === 'ai' && entry.id === sourceTabId), compute insert position
= sourceIndex >= 0 ? sourceIndex + 1 : updatedOrder.length, and splice/construct
updatedOrder to insert { type: 'ai', id: newTabId } at that position (do this
inside the !hasNewTab branch where aiTabs and updatedOrder are built) so the
fork appears directly after the source tab.

---

Outside diff comments:
In `@src/renderer/hooks/agent/useForkConversation.ts`:
- Around line 227-246: The current failure branch in the setSessions callback
unconditionally sets the entire session (session.state, busySource,
thinkingStartTime) to 'idle' even though other AI tabs in the same session may
still be busy; update the map callback in setSessions so that after updating the
specific tab (tab.id === newTabId) you compute whether any aiTabs (including the
newly-updated one) still have a non-idle busy state and only set session.state
to 'idle' / clear busySource and thinkingStartTime when no other aiTabs remain
busy; keep the existing per-tab update (setting that tab to idle, clearing its
thinkingStartTime/awaitingSessionId, and appending errorLog) but derive
session-level fields from s.aiTabs.some(tab => tab.state !== 'idle') (or
equivalent) so you don't clear session-level busy flags while another tab is
running.

---

Nitpick comments:
In `@src/__tests__/renderer/hooks/useForkConversation.test.ts`:
- Around line 144-172: Test is missing a check that the fork also updates
unifiedTabOrder so the new entry appears immediately after the source tab (not
appended after tail); after calling fork('log-s') use getSessions() to inspect
unifiedTabOrder on the session and assert that there is a new { type: 'ai', id:
<newTab.id> } entry directly after the entry whose id equals sourceTab.id and
before the entry with id equal to tailTab.id; update the test surrounding the
fork/getSessions calls (ref: fork, getSessions, unifiedTabOrder, sourceTab.id,
tailTab.id) to include this assertion.
🪄 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: d6e36f18-03c8-4b5e-bc8e-6ba0b1e28dda

📥 Commits

Reviewing files that changed from the base of the PR and between 75a3def and 7cb9050.

📒 Files selected for processing (3)
  • src/__tests__/renderer/hooks/useForkConversation.test.ts
  • src/renderer/App.tsx
  • src/renderer/hooks/agent/useForkConversation.ts

Comment thread src/renderer/hooks/agent/useForkConversation.ts
Previously appended to the end, which misplaced the fork when the source
tab was not last in the unified strip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chr1syy chr1syy added the ready to merge This PR is ready to merge label Apr 19, 2026
@pedramamini pedramamini merged commit 3b523da into RunMaestro:rc Apr 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready to merge This PR is ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants