Skip to content

[chore] Split AgentConversation into hooks and views - #5569

Open
ardaerzin wants to merge 2 commits into
release/v0.106.2from
fe-chore/agent-conversation-split
Open

[chore] Split AgentConversation into hooks and views#5569
ardaerzin wants to merge 2 commits into
release/v0.106.2from
fe-chore/agent-conversation-split

Conversation

@ardaerzin

Copy link
Copy Markdown
Contributor

Context

AgentConversation.tsx had grown to 2692 lines. One component body held the chat stream, two scroll engines, history hydration, onboarding, the composer, attachments, voice input, and the whole transcript render. Touching any one of those meant reading all of them, and the file was still growing (two feature branches landed in it this cycle).

Changes

The blocker was never the length. It was four mutable scroll refs (stick, armBottom, animateBottom, programmaticScroll) written from five call sites and read by two separate sets of effects. Nothing could be lifted out while those refs were shared ambient state.

useScrollIntent now owns them behind three verbs, one per call-site shape:

armGlide()   submit: park follow, arm ONE animated pin to the bottom
armJump()    restore/adopt: arm ONE instant jump, follow untouched
follow()     a send that leaves now (queue release, channelled run)

Producers state intent. They never touch a ref. With that seam the rest moves out whole:

assets/      conversationLayout, runError, messageParts, scrollGeometry
hooks/       useScrollIntent, useTranscriptScroll, useVirtuosoTranscript,
             useAgentChatSession, useSessionHydration, useComposerDraft,
             useComposerAttachments, useVoiceComposer, useTurnInspector,
             useOnboardingChat, useFirstRunSeed
components/  MessageRow, TurnActivity, AgentTurn, AgentTranscript,
             TranscriptPlaceholder, AgentComposerDock

AgentConversation.tsx is 679 lines and reads as a composition root.

Two changes are not pure moves, both deliberate:

AgentTurn is memoized. Previously renderMessage built every row inline, so each streamed token re-rendered every settled turn above the active one. Turn numbers are also precomputed once per message set. The old turnOfAssistant rescanned the message list per rendered row, which made the transcript O(n^2) on every commit.

useComposerAttachments returns bindDropTarget(isBlocked) rather than a static handler object. The blocked-drop guard depends on the recorder, which depends on addFiles, which would be circular. The predicate is read at event time, so no ref crosses the boundary.

Tests

  • pnpm --filter @agenta/oss exec tsc --noEmit passes with zero errors, as do @agenta/entity-ui and @agenta/ee.
  • pnpm lint-fix across the repo makes no changes. Prettier clean.
  • Effect bodies, dependency arrays and useState lazy initializers moved verbatim, comments included. Several of those comments document real races and StrictMode hazards, so they were not rewritten.
  • This branch was rebuilt against current release/v0.106.1 rather than rebased, because the voice input and file upload features landed inside the regions this refactor deletes. Verified line by line: of 357 lines those features added, every behavioural one is present. NEXT_PUBLIC_AGENT_VOICE_INPUT and NEXT_PUBLIC_AGENT_FILE_UPLOADS both still reach live code.

Not runtime verified. The gates above are static.

What to QA

  • Send a message while scrolled up. The jump pill appears, and clicking it glides to the bottom.
  • Ask something with a long answer. The question pins to the top and holds there while the answer streams in.
  • Expand a tool card while reading scrolled-up history. Your place holds, the view does not jump.
  • Reload mid approval. The queue holds new sends, and answering the gate resumes the run.
  • Record a voice message with NEXT_PUBLIC_AGENT_VOICE_INPUT on. Empty composer sends it. Composer with text attaches it instead.
  • Drag a blocked file type onto the panel. The tab does not navigate away and the cursor shows "no drop".
  • Regression: switch sessions and switch revisions. The transcript and composer draft survive both.

@dosubot dosubot Bot added the size:XXL This PR changes 1000+ lines, ignoring generated files. label Jul 30, 2026
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agenta-documentation Ready Ready Preview Jul 30, 2026 4:14pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a smoother chat transcript with virtualized rendering, scroll anchoring, and a “Jump to latest” control.
    • Improved composer support for attachments, drag-and-drop uploads, voice dictation, queued messages, and approval prompts.
    • Enhanced onboarding with templates, optimistic first messages, “Continue in IDE,” and start-over actions.
    • Added clearer turn activity indicators, turn inspection, resend controls, and in-chat run error messages.
  • Bug Fixes

    • Improved session restoration, message persistence, and handling of interrupted or resumed runs.

Walkthrough

AgentConversation is refactored into session, onboarding, composer, transcript, scrolling, and turn-rendering hooks/components. New utilities cover message visibility, errors, layout, attachments, drafts, voice input, hydration, virtualization, and inspector state.

Changes

Agent chat refactor

Layer / File(s) Summary
Session lifecycle and hydration
web/oss/src/components/AgentChatSlice/AgentConversation.tsx, hooks/useAgentChatSession.ts, hooks/useSessionHydration.ts, assets/runError.ts
Session streaming, hydration, persistence, error stamping, cancellation, committed-revision handling, and activity tracking are delegated to session-scoped hooks.
Onboarding and submission orchestration
web/oss/src/components/AgentChatSlice/AgentConversation.tsx, hooks/useOnboardingChat.ts, hooks/useFirstRunSeed.ts
Template provenance, first-run prompts, optimistic onboarding, IDE handoff, approval gating, and shared submission cleanup are centralized.
Composer state and dock
components/AgentComposerDock.tsx, hooks/useComposerDraft.ts, hooks/useComposerAttachments.ts, hooks/useVoiceComposer.ts
The composer dock now handles rich input, queues, approvals, attachments, drafts, voice recording, onboarding actions, and upload gating.
Transcript layout and scrolling
components/AgentTranscript.tsx, components/TranscriptPlaceholder.tsx, hooks/useTranscriptScroll.ts, hooks/useVirtuosoTranscript.tsx, hooks/useScrollIntent.ts, assets/*
Transcript rendering supports virtualized and native scrolling with restoration, live-edge following, anchor preservation, placeholders, fades, and jump-to-latest controls.
Turn rendering and inspection
components/AgentTurn.tsx, components/MessageRow.tsx, components/TurnActivity.tsx, hooks/useTurnInspector.ts, assets/messageParts.ts
Message rows, activity indicators, inspection controls, resend actions, visibility checks, turn numbering, and offscreen rendering are extracted into reusable components and helpers.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed It clearly summarizes the main refactor: splitting AgentConversation into hooks and view components.
Description check ✅ Passed It accurately describes the refactor, extracted modules, and the noted behavioral changes, matching the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fe-chore/agent-conversation-split

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.

@dosubot dosubot Bot added Frontend refactoring A code change that neither fixes a bug nor adds a feature labels Jul 30, 2026
AgentConversation.tsx: 2692 -> 679 lines, across 22 modules.

The seam that made this possible: four mutable scroll refs (stick,
armBottom, animateBottom, programmaticScroll) were written from five call
sites and read by two sets of effects. useScrollIntent owns them behind
three verbs — armGlide() on submit, armJump() on restore/adopt, follow()
for a send that leaves now — so producers never touch raw refs and the two
scroll engines can be lifted out whole.

  assets/      conversationLayout, runError, messageParts, scrollGeometry
  hooks/       useScrollIntent, useTranscriptScroll, useVirtuosoTranscript,
               useAgentChatSession, useSessionHydration, useComposerDraft,
               useComposerAttachments, useVoiceComposer, useTurnInspector,
               useOnboardingChat, useFirstRunSeed
  components/  MessageRow, TurnActivity, AgentTurn, AgentTranscript,
               TranscriptPlaceholder, AgentComposerDock

AgentTurn is memoized and turn numbers are precomputed once per message
set — previously every settled turn re-rendered on each streamed token and
the turn lookup was an O(n^2) scan.

Split against current content so the voice-input and file-upload features
survive: useVoiceComposer is a new module for the recorder state (it has no
home in the pre-feature structure), and useComposerAttachments owns
uploadsEnabled plus bindDropTarget(isBlocked) — a predicate read at event
time, since the blocked-drop guard depends on the recorder which depends on
addFiles. Verified line-by-line against the upstream diff.
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Railway Preview Environment

Preview URL https://gateway-production-71b8.up.railway.app/w
Image tag pr-5569-5d185c9
Status Failed
Railway logs Open logs
Logs View workflow run
Updated at 2026-07-30T16:32:04.348Z

@ardaerzin
ardaerzin changed the base branch from release/v0.106.1 to release/v0.106.2 July 30, 2026 16:13
@ardaerzin

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 6

🧹 Nitpick comments (11)
web/oss/src/components/AgentChatSlice/AgentConversation.tsx (1)

417-427: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider assigning handleSubmitRef from an effect (or memoizing handleSubmit).

Line 427 mutates the ref during render. It happens to be safe here (consumers read it inside effects/events, which run after commit), but a render-phase side effect is fragile if a future consumer reads it during render. useEffect(() => { handleSubmitRef.current = handleSubmit }) makes the contract explicit.

web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts (3)

202-221: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse markLiveGate here instead of writing liveGateInteractionRef directly.

Line 204 duplicates the only other write path (markLiveGate, line 194); routing both through one setter keeps the "live gate" invariant in one place.


307-333: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Full transcript scan on every commit.

This effect re-walks every message and part on each throttled commit (~20×/s while streaming). data-committed-revision only ever arrives on the streaming tail, so scanning the last message (or messages after a cursor index) keeps the hot path O(1) in transcript length.


344-367: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Swallowed control-plane failures leave no trace.

Both killSession (line 359) and commandSessionStream (line 366) discard errors silently, so a failed cancel — where the runner keeps running and billing — is invisible. A console.warn in the catch keeps the UX unchanged while making the failure diagnosable.

🛠️ Proposed change
-                .catch(() => {})
+                .catch((err) => console.warn("[AgentChat] killSession failed:", err))
             return
         }
-        commandSessionStream({sessionId, projectId}).catch(() => {})
+        commandSessionStream({sessionId, projectId}).catch((err) =>
+            console.warn("[AgentChat] cancel command failed:", err),
+        )
web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx (3)

259-259: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Derive one blocked flag instead of re-deriving it per control.

composerDisabled is documented as "IDE hand-off / no model key", which is exactly what onboardingActive ? ideHandoffActive : modelBlocked recomputes at Lines 259 and 303-305. Two sources of truth for the same state will drift; prefer passing (and using) the single flag for input, mic and attach.

Also applies to: 303-305, 327-327


250-396: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider memoizing the prefix/header/trailing JSX slots.

These element trees are rebuilt on every dock render and handed to the lazily-loaded Lexical input as props, defeating any memoization inside it. useMemo per slot (plus a stable onSubmit/onPasteFile via useCallback) keeps the heavy editor from re-rendering on unrelated state changes such as busy or queue updates.

As per coding guidelines, "Memoize inline arrays containing objects or JSX when passing them as props to avoid unnecessary rerenders."

Source: Coding guidelines


37-49: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Trim the narrative comment blocks.

Several of these blocks are multi-paragraph design rationale rather than surprising-constraint notes (the layout-shift and lazy-ref ordering notes do qualify; the "Owner call" / strip-rhythm prose does not).

As per coding guidelines, "Keep in-code comments to at most one short line; use longer comments only for genuinely surprising constraints such as bugs, races, or ordering requirements."

Also applies to: 169-177, 219-224

Source: Coding guidelines

web/oss/src/components/AgentChatSlice/hooks/useComposerDraft.ts (2)

51-62: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Unmount capture also fires on sessionId change.

The effect is keyed on sessionId, so a session switch within a mounted pane runs this cleanup with the previous sessionId while reading the new editor content — persisting text under the wrong key. If panes are strictly one-per-session (as the doc comment implies) this is unreachable today; a sessionId ref snapshot would make it order-independent.


17-19: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer RefObject<boolean> over React.MutableRefObject.

In React 19's type definitions, ref types are unified under RefObject, making MutableRefObject a deprecated shorthand. Using RefObject also matches richInputRef and removes an unimported React. namespace reference.

♻️ Proposed change
@@
-    revealPlayedRef: React.MutableRefObject<boolean>
+    revealPlayedRef: RefObject<boolean>
web/oss/src/components/AgentChatSlice/hooks/useVoiceComposer.ts (2)

29-40: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc block is attached to the wrong declaration.

The send-vs-attach rationale sits above voiceEnabled (Line 40), whose own one-liner then follows it; move it onto voiceWillSend/startVoiceMessage where the decision actually lives.


46-73: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Wrap startVoiceMessage/dismissMicError in useCallback.

Both are handed down as props (onStartAudio, onDismiss) from AgentComposerDock; new identities each render block any memoization of the voice controls.

As per coding guidelines, "Minimize React re-renders with useMemo, useCallback, and React.memo where appropriate; avoid unstable inline functions and objects".

Source: Coding guidelines


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 763bfad5-e5ff-4f7d-99b0-4081a1fe4d77

📥 Commits

Reviewing files that changed from the base of the PR and between a0ef2cc and 3f0cce2.

📒 Files selected for processing (22)
  • web/oss/src/components/AgentChatSlice/AgentConversation.tsx
  • web/oss/src/components/AgentChatSlice/assets/conversationLayout.ts
  • web/oss/src/components/AgentChatSlice/assets/messageParts.ts
  • web/oss/src/components/AgentChatSlice/assets/runError.ts
  • web/oss/src/components/AgentChatSlice/assets/scrollGeometry.ts
  • web/oss/src/components/AgentChatSlice/components/AgentComposerDock.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentTranscript.tsx
  • web/oss/src/components/AgentChatSlice/components/AgentTurn.tsx
  • web/oss/src/components/AgentChatSlice/components/MessageRow.tsx
  • web/oss/src/components/AgentChatSlice/components/TranscriptPlaceholder.tsx
  • web/oss/src/components/AgentChatSlice/components/TurnActivity.tsx
  • web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts
  • web/oss/src/components/AgentChatSlice/hooks/useComposerAttachments.ts
  • web/oss/src/components/AgentChatSlice/hooks/useComposerDraft.ts
  • web/oss/src/components/AgentChatSlice/hooks/useFirstRunSeed.ts
  • web/oss/src/components/AgentChatSlice/hooks/useOnboardingChat.ts
  • web/oss/src/components/AgentChatSlice/hooks/useScrollIntent.ts
  • web/oss/src/components/AgentChatSlice/hooks/useSessionHydration.ts
  • web/oss/src/components/AgentChatSlice/hooks/useTranscriptScroll.ts
  • web/oss/src/components/AgentChatSlice/hooks/useTurnInspector.ts
  • web/oss/src/components/AgentChatSlice/hooks/useVirtuosoTranscript.tsx
  • web/oss/src/components/AgentChatSlice/hooks/useVoiceComposer.ts

Comment on lines +240 to +267
useEffect(() => {
if (!error) return
const parsed = parseAgentRunError(error)
setMessages((prev) => {
const last = prev.length > 0 ? prev[prev.length - 1] : undefined
const existing = (last?.metadata as {runError?: {message?: string}} | undefined)
?.runError
if (last?.role === "assistant") {
if (existing?.message === parsed.message) return prev // already stamped
const next = [...prev]
next[next.length - 1] = {
...last,
metadata: {...(last.metadata as object | undefined), runError: parsed},
}
return next
}
// No trailing assistant turn (failed before one existed) — add a minimal carrier.
return [
...prev,
{
id: `run-error-${generateId()}`,
role: "assistant",
parts: [],
metadata: {runError: parsed},
} as (typeof prev)[number],
]
})
}, [error, setMessages])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

@ai-sdk/react useChat error reset on new sendMessage

💡 Result:

In the AI SDK (specifically starting from newer versions), the error state returned by the useChat hook does not automatically reset when you call sendMessage [1][2][3]. If an error occurs, it persists in the state, often requiring explicit handling to clear it so that subsequent interactions can proceed smoothly [1][2][4]. To manage this, you have a few options: 1. Use clearError: Most recent versions of the @ai-sdk/react package provide a clearError function directly from the useChat hook [3][5][6]. You can call this method before or during your submission logic to reset the error state [1][3]. const { sendMessage, error, clearError } = useChat; const handleSubmit = (e) => { e.preventDefault; if (error) clearError; // Clear the error before sending a new message sendMessage({ text: input }); }; 2. Manual State Reset: If you are on an older version that lacks clearError, or if you need custom behavior, you can use the setMessages function to manipulate the message array. A common pattern is to remove the last (failed) message if the assistant stream was interrupted before sending a new request [2][4]. 3. Error Handling Callback: You can also use the onError callback provided in the useChat options to handle side effects when an error occurs, though this does not automatically clear the state for the UI [2][4][7]. If you find that your UI remains stuck or disabled due to the error, ensure that you are checking the error state in your conditional rendering (e.g., to enable/disable input fields or show/hide retry buttons) and explicitly invoking a reset mechanism [2][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file and package info =="
git ls-files | rg '(^|/)useAgentChatSession\.ts$|(^|/)package\.json$|pnpm-lock\.yaml$|package-lock\.json$|yarn\.lock$' || true

echo
echo "== useAgentChatSession outline/sections =="
if [ -f web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts ]; then
  wc -l web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts
  sed -n '1,340p' web/oss/src/components/AgentChatSlice/hooks/useAgentChatSession.ts | cat -n
fi

echo
echo "== dependency declarations for `@ai-sdk/react` ai package =="
rg -n '"(`@ai-sdk/react`|ai)"|(`@ai-sdk/react`|ai)\@' package.json web/package.json pnpm-lock.yaml web/pnpm-lock.yaml . 2>/dev/null | head -120

Repository: Agenta-AI/agenta

Length of output: 36704


🌐 Web query:

vercel ai useChat clearError reset error new submission @ai-sdk/react 3

💡 Result:

In the Vercel AI SDK (specifically @ai-sdk/react), you can manage error states in the useChat hook using the built-in clearError function [1][2]. The clearError function is available on the object returned by useChat and allows you to programmatically clear any existing error state without needing to trigger a reload or perform a new submission [1][3]. Usage: const { error, clearError } = useChat; // To clear the error: clearError; This is particularly useful when you want to reset the UI or allow the user to try again after a failed attempt without leaving the error message permanently displayed [4]. If you are using an older version of the SDK where clearError is not available, common workarounds include using the regenerate function to attempt a retry, or re-initializing the chat state if necessary [5][6]. For full control, you can also process errors via the onError callback option provided when initializing useChat [5][6].

Citations:


Clear the previous error before submitting again, or track the stamped error separately.

useChat keeps error until it is cleared explicitly, and the tail-based dedupe only prevents overwriting the same message. If the last turn changes to user before error and messages update, this effect can append another run-error-* assistant carrier; call clearError(error) before sendMessage, or store the parsed error identity in a ref and avoid appending while it remains stamped.

Comment on lines +15 to +20
const toUploadFile = (file: File): UploadFile => ({
uid: `${file.name}-${file.lastModified}-${file.size}`,
name: file.name,
status: "done",
originFileObj: file as UploadFile["originFileObj"],
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect validateIncoming for any dedupe-by-uid behavior, and how uploads keys files.
fd -t f 'attachments.ts' web/oss/src/components/AgentChatSlice/assets --exec cat -n
fd -t f 'useAttachmentUploads.ts' --exec cat -n

Repository: Agenta-AI/agenta

Length of output: 11596


🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f 'useComposerAttachments.ts' --exec sh -c '
  echo "===== $1 ====="
  wc -l "$1"
  cat -n "$1"
  echo
' sh {}

echo "===== references to validateIncoming / removeFile ====="
rg -n "validateIncoming|removeFile|toUploadFile|uploads\.enqueue|attachments\.tsx" web/oss/src/components/AgentChatSlice web -g '*.{ts,tsx,js,jsx}' | head -200

Repository: Agenta-AI/agenta

Length of output: 12461


Mint uid once and make it unique.

file.name-file.lastModified-file.size can produce the same identifier for the same file added via paste and drop, and for distinct files with matching name/size/mtime. That makes removeFile(uid) remove both tray rows, and upload handlers keyed by uid can race or patch the wrong entry. Store the generated uid on the UploadFile, reuse it when enqueueing uploads, and use it as the removal key.

Also applies to: 61-74

Comment on lines +94 to +95
autoStartedSeedRef.current = true
handleSubmitRef.current(firstRunPrompt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Unhandled rejection from the seeded auto-send.

handleSubmitRef.current is typed void | Promise<void> and the concrete handleSubmit in AgentConversation.tsx is async (it awaits filesToParts). A rejection here escapes as an unhandled promise rejection — the same case the codebase already handles via ignoreStreamRejection.

🛡️ Proposed fix
-        handleSubmitRef.current(firstRunPrompt)
+        void Promise.resolve(handleSubmitRef.current(firstRunPrompt)).catch((err) =>
+            console.warn("[AgentChat] first-run seed send failed:", err),
+        )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
autoStartedSeedRef.current = true
handleSubmitRef.current(firstRunPrompt)
autoStartedSeedRef.current = true
void Promise.resolve(handleSubmitRef.current(firstRunPrompt)).catch((err) =>
console.warn("[AgentChat] first-run seed send failed:", err),
)

Comment on lines +183 to +222
const streamIdeBubble = useCallback(() => {
const prompt = richInputRef.current?.getMarkdown().trim() ?? ""
const promptQuote = prompt
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
const full = prompt
? `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen hand it your prompt:\n\n${promptQuote}`
: `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen describe the agent you want it to build.`
const id = `ide-${generateId()}`
const userId = `ide-user-${generateId()}`
intent.armGlide()
setStopped(false)
// Clear the composer — the prompt is now the sent user turn (and the editor is disabled after this).
richInputRef.current?.setMarkdown("")
setMessages(
(prev) =>
[
...prev,
...(prompt
? [{id: userId, role: "user", parts: [{type: "text", text: prompt}]}]
: []),
{id, role: "assistant", parts: [{type: "text", text: ""}]},
] as typeof prev,
)
let shown = 0
const chunk = Math.max(3, Math.ceil(full.length / 36))
const tick = () => {
shown = Math.min(full.length, shown + chunk)
const text = full.slice(0, shown)
setMessages(
(prev) =>
prev.map((m) =>
m.id === id ? {...m, parts: [{type: "text", text}]} : m,
) as typeof prev,
)
if (shown < full.length) ideBubbleTimerRef.current = window.setTimeout(tick, 28)
}
ideBubbleTimerRef.current = window.setTimeout(tick, 120)
}, [setMessages])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A second streamIdeBubble (or a Start-over mid-animation) orphans the previous timer chain.

ideBubbleTimerRef.current is overwritten on each start, so an already-running chain becomes uncancellable: the unmount cleanup (line 227) only clears the newest handle, leaving the older chain to call setMessages after unmount. handleStartOver (line 235) likewise leaves the chain scheduling ticks against a wiped transcript.

🛠️ Proposed fix
+    const cancelIdeBubble = useCallback(() => {
+        if (ideBubbleTimerRef.current) window.clearTimeout(ideBubbleTimerRef.current)
+        ideBubbleTimerRef.current = null
+    }, [])
     const streamIdeBubble = useCallback(() => {
+        cancelIdeBubble()
         const prompt = richInputRef.current?.getMarkdown().trim() ?? ""
     const handleStartOver = useCallback(() => {
+        cancelIdeBubble()
         setMessages(() => [])
         richInputRef.current?.setMarkdown("")
-    }, [setMessages])
+    }, [setMessages, cancelIdeBubble])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const streamIdeBubble = useCallback(() => {
const prompt = richInputRef.current?.getMarkdown().trim() ?? ""
const promptQuote = prompt
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
const full = prompt
? `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen hand it your prompt:\n\n${promptQuote}`
: `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen describe the agent you want it to build.`
const id = `ide-${generateId()}`
const userId = `ide-user-${generateId()}`
intent.armGlide()
setStopped(false)
// Clear the composer — the prompt is now the sent user turn (and the editor is disabled after this).
richInputRef.current?.setMarkdown("")
setMessages(
(prev) =>
[
...prev,
...(prompt
? [{id: userId, role: "user", parts: [{type: "text", text: prompt}]}]
: []),
{id, role: "assistant", parts: [{type: "text", text: ""}]},
] as typeof prev,
)
let shown = 0
const chunk = Math.max(3, Math.ceil(full.length / 36))
const tick = () => {
shown = Math.min(full.length, shown + chunk)
const text = full.slice(0, shown)
setMessages(
(prev) =>
prev.map((m) =>
m.id === id ? {...m, parts: [{type: "text", text}]} : m,
) as typeof prev,
)
if (shown < full.length) ideBubbleTimerRef.current = window.setTimeout(tick, 28)
}
ideBubbleTimerRef.current = window.setTimeout(tick, 120)
}, [setMessages])
const cancelIdeBubble = useCallback(() => {
if (ideBubbleTimerRef.current) window.clearTimeout(ideBubbleTimerRef.current)
ideBubbleTimerRef.current = null
}, [])
const streamIdeBubble = useCallback(() => {
cancelIdeBubble()
const prompt = richInputRef.current?.getMarkdown().trim() ?? ""
const promptQuote = prompt
.split("\n")
.map((line) => `> ${line}`)
.join("\n")
const full = prompt
? `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen hand it your prompt:\n\n${promptQuote}`
: `Prefer to build in your IDE? Install the Agenta skill for Claude Code, Cursor, or any coding agent:\n\n\`\`\`bash\n${IDE_INSTALL_COMMAND}\n\`\`\`\n\nThen describe the agent you want it to build.`
const id = `ide-${generateId()}`
const userId = `ide-user-${generateId()}`
intent.armGlide()
setStopped(false)
// Clear the composer — the prompt is now the sent user turn (and the editor is disabled after this).
richInputRef.current?.setMarkdown("")
setMessages(
(prev) =>
[
...prev,
...(prompt
? [{id: userId, role: "user", parts: [{type: "text", text: prompt}]}]
: []),
{id, role: "assistant", parts: [{type: "text", text: ""}]},
] as typeof prev,
)
let shown = 0
const chunk = Math.max(3, Math.ceil(full.length / 36))
const tick = () => {
shown = Math.min(full.length, shown + chunk)
const text = full.slice(0, shown)
setMessages(
(prev) =>
prev.map((m) =>
m.id === id ? {...m, parts: [{type: "text", text}]} : m,
) as typeof prev,
)
if (shown < full.length) ideBubbleTimerRef.current = window.setTimeout(tick, 28)
}
ideBubbleTimerRef.current = window.setTimeout(tick, 120)
}, [setMessages, cancelIdeBubble])

Comment on lines +118 to +151
const adopt = (serverMsgs: UIMessage[] | null) => {
if (cancelled || !serverMsgs || serverMsgs.length === 0) return
const prev = messagesRef.current
if (busyRef.current) return
// Adopt the server transcript when it is strictly ahead by count, OR when our LOCAL tail
// is stuck paused (mid-approval) while the server has moved past it to a terminal turn — a
// resume that completed on another device. Count alone misses the latter (same bubble
// count) and was silently propped up by the now-removed duplicate user row; the server's
// `paused` flag rides the runner's `done.stopReason` through `transcriptToMessages`.
const serverAheadByCount = serverMsgs.length > prev.length
const localTailPaused = getPendingApprovals(prev).length > 0
const serverTail = serverMsgs[serverMsgs.length - 1] as
| {role?: string; metadata?: {paused?: boolean}}
| undefined
// The paused-tail exception adopts a resume that completed elsewhere, so the server must
// NOT be behind (>= guards against a lagging snapshot discarding newer local approval
// state) and its tail must be a finished assistant turn — not a shorter, older stream.
const serverTailComplete =
serverMsgs.length >= prev.length &&
serverTail?.role === "assistant" &&
!serverTail.metadata?.paused &&
getPendingApprovals(serverMsgs).length === 0
if (!serverAheadByCount && !(localTailPaused && serverTailComplete)) return
serverMsgs.forEach((m) => {
seenIdsRef.current.add(m.id)
restoredIdsRef.current.add(m.id)
})
intent.armJump()
setMessages(serverMsgs)
persistMessages({id: sessionId, messages: serverMsgs})
}
// The first result may itself be the disk-restored records log; the callback re-applies
// the same guarded adoption when the guaranteed background revalidation lands.
loadSessionMessages(sessionId, adopt).then(adopt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

adopt compares against messagesRef.current, which lags an un-committed setMessages.

adopt is wired both as the refresh callback and as the then handler (line 151). If both fire before React commits the first setMessages, the second call still sees the pre-adoption prev, so a shorter/stale snapshot can win the race and overwrite the newer transcript. Tracking the last adopted length in a local variable/ref alongside messagesRef would make the guard order-independent.

Comment on lines +292 to +315
useEffect(() => {
const el = scrollRef.current
if (!el) return
const release = () => {
if (!stickRef.current) return
stickRef.current = false
setShowJump(!atLiveEdge(el))
}
const onSelectionChange = () => {
if (!stickRef.current) return
const sel = window.getSelection()
if (!sel || sel.isCollapsed || sel.rangeCount === 0) return
if (sel.anchorNode && el.contains(sel.anchorNode)) release()
}
const onClick = (e: MouseEvent) => {
if ((e.target as HTMLElement | null)?.closest("a")) release()
}
document.addEventListener("selectionchange", onSelectionChange)
el.addEventListener("click", onClick)
return () => {
document.removeEventListener("selectionchange", onSelectionChange)
el.removeEventListener("click", onClick)
}
}, [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

SC-4 select/click follow-release is inert once Virtuoso takes over.

This effect has [] deps and captures el = scrollRef.current once at mount, with no useVirtuoso gate. scrollRef is only attached when the plain container renders (!useVirtuoso || messages.length === 0 in AgentTranscript.tsx). For a conversation that starts empty with virtualization on, the plain div mounts once while messages.length === 0 (so this effect wires up listeners to it), then unmounts permanently as soon as the first message arrives and Virtuoso takes over. The el-bound click listener becomes inert (detached node) and el.contains(...) inside onSelectionChange will never match live selections again — text-selection/link-click no longer releases follow while streaming under Virtuoso, for the entire session.

🐛 Suggested direction

Re-run (or re-bind) this effect whenever the container is (re)attached, e.g. by tracking mount via a callback ref/state instead of only reading scrollRef.current inside a mount-only effect, and/or add the equivalent release logic to useVirtuosoTranscript's scroller (setVirtScroller already has the node).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Frontend refactoring A code change that neither fixes a bug nor adds a feature size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant