Skip to content

fix: followup panel routing and stream error handling#598

Open
ngoiyaeric wants to merge 3 commits into
mainfrom
fix/followup-panel-streaming-5274824833186492759
Open

fix: followup panel routing and stream error handling#598
ngoiyaeric wants to merge 3 commits into
mainfrom
fix/followup-panel-streaming-5274824833186492759

Conversation

@ngoiyaeric
Copy link
Copy Markdown
Collaborator

@ngoiyaeric ngoiyaeric commented May 7, 2026

  • Remove hardcoded 'resolution_search' action from FollowupPanel to fix text-only routing
  • Add try-catch error handling to FollowupPanel submission
  • Implement try-catch-finally blocks in querySuggestor and processEvents to ensure streams are closed on error
  • Reduce update throttle to 100ms and ensure final flush in querySuggestor

PR created automatically by Jules for task 5274824833186492759 started by @ngoiyaeric

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced error handling with try/catch blocks in form submission and suggestion generation.
    • Improved state management to only apply UI updates on successful operations.
  • Refactor

    • Centralized cleanup and finalization logic for better resource management.
    • Optimized throttling intervals for improved responsiveness in suggestion updates.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 7, 2026

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

Project Deployment Actions Updated (UTC)
qcx Error Error May 7, 2026 2:25pm

@google-labs-jules
Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@qodo-code-review
Copy link
Copy Markdown
Contributor

Review Summary by Qodo

Fix followup panel routing and stream error handling

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add try-catch-finally error handling to processEvents function
• Remove hardcoded 'resolution_search' action from FollowupPanel submission
• Add error handling to FollowupPanel form submission with try-catch
• Implement try-catch-finally in querySuggestor with reduced throttle to 100ms
• Ensure final flush of related queries and proper stream closure on errors
Diagram
flowchart LR
  A["FollowupPanel<br/>removes hardcoded action"] -->|submit| B["processEvents<br/>try-catch-finally"]
  B -->|on success| C["querySuggestor<br/>try-catch-finally"]
  C -->|streams| D["objectStream<br/>with final flush"]
  B -->|on error| E["Error handling<br/>& cleanup"]
  C -->|on error| E
Loading

Grey Divider

File Changes

1. app/actions.tsx Error handling +128/-124

Add error handling to processEvents function

• Wrapped entire processEvents function logic in try-catch-finally block
• Moved isGenerating.done(false) and uiStream.done() to finally block for guaranteed execution
• Added error logging for debugging stream failures
• Ensures proper cleanup and stream closure even when errors occur

app/actions.tsx


2. components/followup-panel.tsx 🐞 Bug fix +12/-10

Remove hardcoded action and add error handling

• Removed hardcoded "resolution_search" action from form submission
• Added try-catch error handling around submit() call
• Fixed quote style consistency in form data append
• Ensures graceful error handling with console logging

components/followup-panel.tsx


3. lib/agents/query-suggestor.tsx Error handling +36/-26

Add error handling and optimize stream throttling

• Wrapped streamObject and stream processing in try-catch-finally block
• Reduced UPDATE_THROTTLE from 200ms to 100ms for better responsiveness
• Added final flush logic in finally block to ensure last update is sent
• Added conditional cache storage to prevent empty results
• Ensures objectStream.done() is always called for proper cleanup

lib/agents/query-suggestor.tsx


Grey Divider

ⓘ You are approaching your monthly quota for Qodo. Upgrade your plan

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown
Contributor

qodo-code-review Bot commented May 7, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Double stream finalization 🐞 Bug ☼ Reliability
Description
In submit()'s processEvents(), the 'inquire' early-return path calls uiStream.done() and
isGenerating.done() before returning, but the enclosing finally block always calls uiStream.done()
and isGenerating.done(false) again. This can throw during finally and break inquiry submissions (or
override the intended early return cleanup).
Code

app/actions.tsx[R414-431]

+      if (action.object.next === 'inquire') {
+        const inquiry = await inquire(uiStream, messages)
+        uiStream.done()
+        isGenerating.done()
+        isCollapsed.done(false)
+        aiState.done({
+          ...aiState.get(),
+          messages: [
+            ...aiState.get().messages,
+            {
+              id: nanoid(),
+              role: 'assistant',
+              content: `inquiry: ${inquiry?.question}`
+            }
+          ]
+        })
+        return
+      }
Evidence
The inquire branch explicitly finalizes uiStream/isGenerating and returns; because it is inside a
try, the finally block still runs and finalizes the same streamables again. Elsewhere in the same
file (resolution search) the code relies on a single finally to close streams, indicating the
intended pattern is single-close ownership in one place.

app/actions.tsx[404-431]
app/actions.tsx[536-541]
app/actions.tsx[102-200]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`processEvents()` closes `uiStream`/`isGenerating` inside the `action.object.next === 'inquire'` branch and then closes them again in `finally`. Because `finally` runs even after `return`, this creates a double-finalization path.
### Issue Context
Other flows in `submit()` (e.g., resolution search) use a single `finally` to close streamables, which avoids double-close hazards.
### Fix Focus Areas
- app/actions.tsx[404-431]
- app/actions.tsx[536-541]
### Suggested fix
- Remove `uiStream.done()` and `isGenerating.done()` from the `inquire` branch and let the `finally` handle closing.
- If the inquire branch needs different final values, set them (e.g., `isCollapsed.done(false)`) but do not close `uiStream`/`isGenerating` twice.
- Alternatively, add a boolean guard (e.g., `let finalized = false`) so the `finally` block only finalizes when not already finalized.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. streamText not closed on throw 🐞 Bug ☼ Reliability
Description
processEvents() creates streamText inside the try block but does not ensure it is closed in
catch/finally when researcher()/writer()/querySuggestor() throws. This can leave the streamed
BotMessage value hanging and leak resources in error scenarios (and can also leave isCollapsed never
done if the throw happens before isCollapsed.done(...)).
Code

app/actions.tsx[R433-499]

+      isCollapsed.done(true)
+      let answer = ''
+      let toolOutputs: ToolResultPart[] = []
+      let errorOccurred = false
+      const streamText = createStreamableValue<string>()
+      uiStream.update(<Spinner />)
+
+      while (
+        useSpecificAPI
+          ? answer.length === 0
+          : answer.length === 0 && !errorOccurred
+      ) {
+        const { fullResponse, hasError, toolResponses } = await researcher(
+          currentSystemPrompt,
+          uiStream,
+          streamText,
+          messages,
+          mapProvider,
+          useSpecificAPI,
+          drawnFeatures
+        )
+        answer = fullResponse
+        toolOutputs = toolResponses
+        errorOccurred = hasError
+
+        if (toolOutputs.length > 0) {
+          toolOutputs.map(output => {
+            aiState.update({
+              ...aiState.get(),
+              messages: [
+                ...aiState.get().messages,
+                {
+                  id: groupeId,
+                  role: 'tool',
+                  content: JSON.stringify(output.result),
+                  name: output.toolName,
+                  type: 'tool'
+                }
+              ]
+            })
        })
-        })
+        }
    }
-    }
-    if (useSpecificAPI && answer.length === 0) {
-      const modifiedMessages = aiState
-        .get()
-        .messages.map(msg =>
-          msg.role === 'tool'
-            ? {
-                ...msg,
-                role: 'assistant',
-                content: JSON.stringify(msg.content),
-                type: 'tool'
-              }
-            : msg
-        ) as CoreMessage[]
-      const latestMessages = modifiedMessages.slice(maxMessages * -1)
-      answer = await writer(
-        currentSystemPrompt,
-        uiStream,
-        streamText,
-        latestMessages
-      )
-    } else {
-      streamText.done()
-    }
+      if (useSpecificAPI && answer.length === 0) {
+        const modifiedMessages = aiState
+          .get()
+          .messages.map(msg =>
+            msg.role === 'tool'
+              ? {
+                  ...msg,
+                  role: 'assistant',
+                  content: JSON.stringify(msg.content),
+                  type: 'tool'
+                }
+              : msg
+          ) as CoreMessage[]
+        const latestMessages = modifiedMessages.slice(maxMessages * -1)
+        answer = await writer(
+          currentSystemPrompt,
+          uiStream,
+          streamText,
+          latestMessages
+        )
+      } else {
+        streamText.done()
+      }
Evidence
streamText is created inside the try block and is only explicitly closed on the non-writer happy
path (streamText.done() in the else branch). If any awaited call throws before that line,
execution jumps to catch/finally, which closes uiStream/isGenerating but never closes
streamText. Additionally, writer() only calls streamText.done() at normal completion and does
not wrap streaming in a try/finally, so exceptions inside writer() will also skip stream
finalization.

app/actions.tsx[433-541]
lib/agents/writer.tsx[34-49]
lib/agents/researcher.tsx[82-167]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`streamText` is created and used for streaming the assistant response, but it is not guaranteed to be finalized when an exception is thrown. This can leave the streamable value open even though `uiStream` is closed in `finally`.
### Issue Context
- `researcher()` streams and updates `streamText` but does not finalize it.
- `writer()` finalizes `streamText` only on normal completion.
### Fix Focus Areas
- app/actions.tsx[433-541]
- lib/agents/writer.tsx[34-49]
### Suggested fix
- Hoist `streamText` to an outer scope so it can be finalized from `finally`:
- `let streamText: ReturnType<typeof createStreamableValue<string>> | undefined;`
- assign it where it is created.
- In `finally`, call `streamText?.done()` (or `streamText?.error(error)` in `catch`) if it was created and not yet finalized.
- Also ensure `isCollapsed` is finalized in error paths (either in `finally` with a guard, or immediately before operations that may throw).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack

Warning

Rate limit exceeded

@ngoiyaeric has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 31 minutes and 11 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 86d4f189-d0f1-450d-8fa9-723402fee99c

📥 Commits

Reviewing files that changed from the base of the PR and between 7ab4e93 and 64e4c2d.

📒 Files selected for processing (4)
  • actions_part1.tsx
  • actions_part2.tsx
  • app/actions.tsx
  • app_actions.patch

Walkthrough

This PR improves error handling and stream finalization across three interconnected components. The server action refactors its main control flow into try/catch/finally to centralize cleanup, removes a fixed delay, and adjusts researcher invocation. The client form submission wraps the server call in error handling. The agent query generator applies the same error handling pattern with throttled updates and conditional caching.

Changes

Error Handling & Stream Finalization

Layer / File(s) Summary
Server Action Control Flow
app/actions.tsx
The processEvents logic is refactored into try/catch/finally. Inquiry and response generation move into the guarded block, the researcher argument order changes, the 500ms delay before aiState.done is removed, and cleanup (isGenerating.done and uiStream.done) is centralized in finally for both success and error paths.
Followup Form Submission
components/followup-panel.tsx
handleSubmit wraps the submit(formData) call in try/catch. FormData is constructed with the input key, and on success the userMessage and responseMessage are appended to UI state and input is cleared; on error the exception is logged to console.
Agent Query Streaming
lib/agents/query-suggestor.tsx
querySuggestor wraps streamObject in try/catch/finally. finalRelatedQueries initializes with { items: [] }, updates are throttled to 100ms, the final state is guaranteed in finally before objectStream.done(), and cache population is moved after the guarded block and only occurs when items is non-empty.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • QueueLab/QCX#447: Both PRs modify app/actions.tsx control flow for submit handling—adding try/catch/finally-based cleanup and changes to assistant message production.
  • QueueLab/QCX#589: Both PRs make overlapping changes to lib/agents/query-suggestor.tsx (streaming error handling, throttling/flush behavior, and cache-write timing).
  • QueueLab/QCX#516: Both PRs modify components/followup-panel.tsx handleSubmit flow (error handling and post-submit UI updates).

Suggested labels

Review effort 3/5

Poem

🐰 Three streams now caught in careful hands,
Errors gracefully handled with finally lands,
Throttle and cache in harmony play,
Cleanup assured—no delays in the way!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 PR title accurately describes the main changes: it addresses followup panel routing issues and implements error handling for streams, which are the primary objectives of this changeset.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/followup-panel-streaming-5274824833186492759

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.

Comment thread app/actions.tsx
Comment on lines +414 to +431
if (action.object.next === 'inquire') {
const inquiry = await inquire(uiStream, messages)
uiStream.done()
isGenerating.done()
isCollapsed.done(false)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'assistant',
content: `inquiry: ${inquiry?.question}`
}
]
})
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

1. Double stream finalization 🐞 Bug ☼ Reliability

In submit()'s processEvents(), the 'inquire' early-return path calls uiStream.done() and
isGenerating.done() before returning, but the enclosing finally block always calls uiStream.done()
and isGenerating.done(false) again. This can throw during finally and break inquiry submissions (or
override the intended early return cleanup).
Agent Prompt
### Issue description
`processEvents()` closes `uiStream`/`isGenerating` inside the `action.object.next === 'inquire'` branch and then closes them again in `finally`. Because `finally` runs even after `return`, this creates a double-finalization path.

### Issue Context
Other flows in `submit()` (e.g., resolution search) use a single `finally` to close streamables, which avoids double-close hazards.

### Fix Focus Areas
- app/actions.tsx[404-431]
- app/actions.tsx[536-541]

### Suggested fix
- Remove `uiStream.done()` and `isGenerating.done()` from the `inquire` branch and let the `finally` handle closing.
- If the inquire branch needs different final values, set them (e.g., `isCollapsed.done(false)`) but do not close `uiStream`/`isGenerating` twice.
- Alternatively, add a boolean guard (e.g., `let finalized = false`) so the `finally` block only finalizes when not already finalized.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread app/actions.tsx
Comment on lines +433 to +499
isCollapsed.done(true)
let answer = ''
let toolOutputs: ToolResultPart[] = []
let errorOccurred = false
const streamText = createStreamableValue<string>()
uiStream.update(<Spinner />)

while (
useSpecificAPI
? answer.length === 0
: answer.length === 0 && !errorOccurred
) {
const { fullResponse, hasError, toolResponses } = await researcher(
currentSystemPrompt,
uiStream,
streamText,
messages,
mapProvider,
useSpecificAPI,
drawnFeatures
)
answer = fullResponse
toolOutputs = toolResponses
errorOccurred = hasError

if (toolOutputs.length > 0) {
toolOutputs.map(output => {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'tool',
content: JSON.stringify(output.result),
name: output.toolName,
type: 'tool'
}
]
})
})
})
}
}
}

if (useSpecificAPI && answer.length === 0) {
const modifiedMessages = aiState
.get()
.messages.map(msg =>
msg.role === 'tool'
? {
...msg,
role: 'assistant',
content: JSON.stringify(msg.content),
type: 'tool'
}
: msg
) as CoreMessage[]
const latestMessages = modifiedMessages.slice(maxMessages * -1)
answer = await writer(
currentSystemPrompt,
uiStream,
streamText,
latestMessages
)
} else {
streamText.done()
}
if (useSpecificAPI && answer.length === 0) {
const modifiedMessages = aiState
.get()
.messages.map(msg =>
msg.role === 'tool'
? {
...msg,
role: 'assistant',
content: JSON.stringify(msg.content),
type: 'tool'
}
: msg
) as CoreMessage[]
const latestMessages = modifiedMessages.slice(maxMessages * -1)
answer = await writer(
currentSystemPrompt,
uiStream,
streamText,
latestMessages
)
} else {
streamText.done()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action required

2. Streamtext not closed on throw 🐞 Bug ☼ Reliability

processEvents() creates streamText inside the try block but does not ensure it is closed in
catch/finally when researcher()/writer()/querySuggestor() throws. This can leave the streamed
BotMessage value hanging and leak resources in error scenarios (and can also leave isCollapsed never
done if the throw happens before isCollapsed.done(...)).
Agent Prompt
### Issue description
`streamText` is created and used for streaming the assistant response, but it is not guaranteed to be finalized when an exception is thrown. This can leave the streamable value open even though `uiStream` is closed in `finally`.

### Issue Context
- `researcher()` streams and updates `streamText` but does not finalize it.
- `writer()` finalizes `streamText` only on normal completion.

### Fix Focus Areas
- app/actions.tsx[433-541]
- lib/agents/writer.tsx[34-49]

### Suggested fix
- Hoist `streamText` to an outer scope so it can be finalized from `finally`:
  - `let streamText: ReturnType<typeof createStreamableValue<string>> | undefined;`
  - assign it where it is created.
- In `finally`, call `streamText?.done()` (or `streamText?.error(error)` in `catch`) if it was created and not yet finalized.
- Also ensure `isCollapsed` is finalized in error paths (either in `finally` with a guard, or immediately before operations that may throw).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@ngoiyaeric
Copy link
Copy Markdown
Collaborator Author

@coderabbitai its still very slow

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and inspect the relevant files
fd -e ts -e tsx "FollowupPanel" --exec cat {}
echo "---"
fd -e ts -e tsx "querySuggestor" --exec cat {}
echo "---"
fd -e ts -e tsx "processEvents" --exec cat {}

Length of output: 8


🏁 Script executed:

#!/bin/bash
# Try broader searches
echo "=== Tree ==="
tree -L 3 --filelimit 30

echo "=== Search for FollowupPanel ==="
rg -rl "FollowupPanel" --type-add 'web:*.{ts,tsx,js,jsx}'

echo "=== Search for querySuggestor ==="
rg -rl "querySuggestor"

echo "=== Search for processEvents ==="
rg -rl "processEvents"

echo "=== Search for throttle ==="
rg -rn "throttle" -C2

Length of output: 3393


🏁 Script executed:

#!/bin/bash
echo "=== components/followup-panel.tsx ==="
cat components/followup-panel.tsx

echo "=== lib/agents/query-suggestor.tsx ==="
cat lib/agents/query-suggestor.tsx

echo "=== OPTIMIZATION_SUMMARY.md ==="
cat OPTIMIZATION_SUMMARY.md

echo "=== app/actions.tsx (first 200 lines) ==="
head -200 app/actions.tsx

echo "=== app/actions.tsx processEvents section ==="
rg -n "processEvents\|throttle\|stream\|flush\|update\|delay" app/actions.tsx -C2

Length of output: 20927


🏁 Script executed:

#!/bin/bash
echo "=== app/actions.tsx full (lines 200 onwards) ==="
tail -n +200 app/actions.tsx

echo "=== lib/agents/researcher.tsx or similar ==="
fd -e tsx -e ts "researcher" --exec cat {}

echo "=== lib/agents/task-manager.tsx or similar ==="
fd -e tsx -e ts "task-manager" --exec cat {}

echo "=== Check for setTimeout / artificial delays ==="
rg -n "setTimeout\|setInterval\|await.*delay\|sleep" app/actions.tsx -C2

echo "=== Check getModel utility ==="
fd -e ts -e tsx "utils" lib/ --exec grep -n "getModel\|model" {} \;

Length of output: 28186


@ngoiyaeric Here's a detailed breakdown of the specific bottlenecks found in the code:


🐢 Root Causes of Slowness

1. Extra LLM Call Before Streaming Starts (Biggest Impact)

In processEvents(), every text query hits taskManager() first — which makes a full generateObject LLM call — before the researcher even starts streaming. The user sees nothing until this resolves.

User submits → taskManager (full LLM round-trip) → researcher starts streaming
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                This is dead time — no UI feedback

Fix: Either run taskManager with a shorter/cheaper model, add a streaming spinner immediately after submit (currently <Spinner /> only appears inside processEvents after taskManager), or skip it for simple queries:

// In processEvents(), show spinner BEFORE taskManager resolves
uiStream.update(<Spinner />)  //  move this to BEFORE taskManager call

let action: any = { object: { next: 'proceed' } }
if (!skip) {
  const taskManagerResult = await taskManager(messages)
  ...
}

2. Artificial 500ms Delay — Twice

Both processEvents() and processResolutionSearch() have this hardcoded:

await new Promise(resolve => setTimeout(resolve, 500))  // ← unnecessary

This runs before aiState.done() and blocks stream completion. Remove both.

3. querySuggestor Runs Sequentially After Main Response

After the researcher finishes, querySuggestor makes another full LLM call before the followup panel renders:

const relatedQueries = await querySuggestor(uiStream, messages)  // blocks
uiStream.append(<Section title="Follow-up"><FollowupPanel /></Section>)

Fix: Append the FollowupPanel immediately, and run querySuggestor without blocking it:

// Show followup panel immediately — don't wait for related queries
uiStream.append(
  <Section title="Follow-up">
    <FollowupPanel />
  </Section>
)

// Run querySuggestor non-blocking
querySuggestor(uiStream, messages).then(relatedQueries => {
  aiState.done({ ...aiState.get(), messages: [ ...aiState.get().messages, 
    { id: groupeId, role: 'assistant', content: JSON.stringify(relatedQueries), type: 'related' },
    { id: groupeId, role: 'assistant', content: 'followup', type: 'followup' }
  ]})
})

4. getSystemPrompt DB Call Blocks Before processEvents

const currentSystemPrompt = (await getSystemPrompt(userId)) || ''

This DB call happens synchronously before processEvents() fires. Move it inside processEvents() and call processEvents() immediately so the return value is sent to the client sooner.


Summary of Changes

Issue Location Fix
No spinner until taskManager resolves app/actions.tsx processEvents() Move uiStream.update(<Spinner />) before taskManager()
500ms artificial delay × 2 processEvents() & processResolutionSearch() Remove both setTimeout lines
querySuggestor blocks followup panel processEvents() & processResolutionSearch() Show FollowupPanel immediately, don't await querySuggestor
getSystemPrompt blocks early return app/actions.tsx submit fn Move inside processEvents()

Would you like me to plan and generate the specific code changes for all of these fixes?

@google-labs-jules
Copy link
Copy Markdown
Contributor

@coderabbitai its still very slow

I've removed the artificial 500ms delays in the server actions that were contributing to the slowness. This should significantly improve responsiveness.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@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

Caution

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

⚠️ Outside diff range comments (3)
app/actions.tsx (2)

399-403: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider moving getSystemPrompt inside processEvents to reduce time-to-first-byte.

getSystemPrompt(userId) is awaited synchronously before processEvents() is invoked and before the function returns the uiStream.value to the client. Any DB latency here delays the streamable component reaching the client, which directly contributes to the perceived slowness reported on this PR. Moving the await inside the processEvents IIFE lets the streamable values return immediately and the prompt fetch overlap with the spinner render.

Also, userId is hard-coded to 'anonymous', so the per-user dimension of getSystemPrompt is not actually being used — worth confirming whether the call is needed at all on this path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 399 - 403, Move the getSystemPrompt call into
the processEvents async IIFE so the UI stream can return immediately; currently
getSystemPrompt(userId) is awaited before processEvents and delays sending
uiStream.value to the client—update code to declare const userId = 'anonymous'
(or derive real userId) but avoid awaiting getSystemPrompt outside
processEvents, instead await it within processEvents (or remove the call if
per-user prompt isn't needed) and then use the fetched currentSystemPrompt
inside processEvents when composing messages; ensure references to
getSystemPrompt, processEvents, userId, and uiStream.value are updated
accordingly.

102-202: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

processResolutionSearch shadows the parent groupeId and runs detached.

Two related concerns on this branch:

  1. groupeId declared at Line 100 is shadowed and only used inside processResolutionSearch; it's distinct from the outer groupeId at Line 329. The shadowing is fine but confusing — consider renaming for clarity.
  2. processResolutionSearch() at Line 202 is a fire-and-forget async call with no void/error tether. Its try/catch/finally already closes uiStream/isGenerating and calls summaryStream.error, but if something throws synchronously between declaration and the first await (e.g., the file reads at Lines 66–73 already happened, but in future edits this would matter), the rejection would be unhandled. Same pattern already applies to processEvents() at Line 542.

A small .catch(err => console.error(...)) on the IIFE invocation makes the unhandled-rejection surface explicit and prevents Node from logging duplicate stack traces.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 102 - 202, The inner async function
processResolutionSearch shadows the outer groupeId and is invoked
fire-and-forget without tethering errors; rename the inner variable (e.g.,
localGroupId or resolutionGroupId) wherever used inside processResolutionSearch
to avoid confusion with the outer groupeId, and change the invocation of
processResolutionSearch() to attach a rejection handler (e.g.,
processResolutionSearch().catch(err => console.error(...))) or prefix with void
and add .catch to ensure any asynchronous rejection is logged; apply the same
catch-on-invoke pattern used for processEvents() to prevent unhandled promise
rejections.
components/followup-panel.tsx (1)

19-44: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Submission errors leave the user with no UI feedback.

The new try/catch correctly prevents adding a malformed responseMessage on failure and now consistently routes through the server action's centralized error handling. However, on failure:

  • setInput('') is skipped, which is good (user can retry), but the user still sees no error indication — the form simply does nothing.
  • There is no in-flight disabled state on the submit button while await submit(formData) is pending, so the user can fire multiple submissions.

Consider surfacing the error (toast / inline message) and disabling the button during submission. Both are cheap and materially improve the UX of the followup flow that this PR is targeting.

♻️ Sketch
-export function FollowupPanel() {
-  const [input, setInput] = useState('')
+export function FollowupPanel() {
+  const [input, setInput] = useState('')
+  const [isSubmitting, setIsSubmitting] = useState(false)
   ...
-    try {
+    setIsSubmitting(true)
+    try {
       const responseMessage = await submit(formData)
       setMessages(currentMessages => [
         ...currentMessages,
         userMessage,
         responseMessage
       ])
       setInput('')
     } catch (error) {
       console.error('Error submitting followup:', error)
+      // surface to user, e.g. via toast
+    } finally {
+      setIsSubmitting(false)
     }
   }
   ...
-        disabled={input.length === 0}
+        disabled={input.length === 0 || isSubmitting}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/followup-panel.tsx` around lines 19 - 44, handleSubmit currently
doesn't show UI feedback on failure and allows duplicate submissions; add a
local loading state (e.g. isSubmitting) and an error state (e.g. submitError),
check/set isSubmitting at the start of handleSubmit to early-return if already
submitting, set isSubmitting=true before calling submit(formData), and in the
try branch only clear input and push responseMessage to setMessages on success;
in catch set submitError with the caught error (or a user-friendly message) so
the component can render an inline error or trigger a toast, and in finally set
isSubmitting=false; also update the submit button to be disabled when
isSubmitting and show a loading indicator tied to isSubmitting. Ensure
references to handleSubmit, submit, setMessages, setInput, mapData.drawnFeatures
are used to locate the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/actions.tsx`:
- Around line 439-474: The loop continuation condition currently ignores
errorOccurred when useSpecificAPI is true, causing infinite retries; update the
loop in actions.tsx so it also breaks on hasError and add a bounded retry
counter: introduce maxRetries (e.g., 3) and retryCount, increment retryCount
each iteration, and change the while condition to require answer.length === 0 &&
!errorOccurred && retryCount < maxRetries (or include !errorOccurred regardless
of useSpecificAPI), and inside the loop when researcher returns hasError ===
true set errorOccurred and break or let the condition exit; reference the
variables and functions answer, errorOccurred, useSpecificAPI, researcher, and
retryCount/maxRetries when applying the fix.
- Around line 500-539: The processEvents flow fails to finalize aiState when
errors occur or when errorOccurred is true; update processEvents to ensure
aiState.done(...) is always called in the error path and after the loop exit: in
the catch block and immediately after detecting errorOccurred, call
aiState.done(...) with a minimal terminal state (e.g., merge current
aiState.get().messages with a committed assistant error/failure message and set
any terminal flags) so the pending user message is committed and onSetAIState
runs; reference aiState.done, aiState.get, errorOccurred, and the processEvents
try/catch/finally to locate where to add these finalizing calls.
- Around line 413-430: The inquire branch prematurely finalizes streams: remove
the explicit uiStream.done() and isGenerating.done() calls inside the if
(action.object.next === 'inquire') block (leaving isCollapsed.done(false) and
the aiState update) so the finally block's cleanup handles finalization;
specifically, update the code around the inquire(...) call and return to not
call uiStream.done() or isGenerating.done() there and rely on the existing
finally that calls isGenerating.done(false) and uiStream.done().

In `@lib/agents/query-suggestor.tsx`:
- Around line 67-78: The throttle initialization prevents the first partial
chunk from being emitted because lastUpdateTime is set to Date.now(); change the
initialization of lastUpdateTime (or logic) so the first chunk always emits —
e.g., set lastUpdateTime = 0 or add a first-chunk guard before the for-await
loop that calls objectStream.update(obj) unconditionally; update references in
the streaming loop that check now - lastUpdateTime > UPDATE_THROTTLE and ensure
lastUpdateTime is updated after that first emit so subsequent throttling still
applies (affecting variables lastUpdateTime, UPDATE_THROTTLE, the for-await-of
over result.partialObjectStream, and objectStream.update).

---

Outside diff comments:
In `@app/actions.tsx`:
- Around line 399-403: Move the getSystemPrompt call into the processEvents
async IIFE so the UI stream can return immediately; currently
getSystemPrompt(userId) is awaited before processEvents and delays sending
uiStream.value to the client—update code to declare const userId = 'anonymous'
(or derive real userId) but avoid awaiting getSystemPrompt outside
processEvents, instead await it within processEvents (or remove the call if
per-user prompt isn't needed) and then use the fetched currentSystemPrompt
inside processEvents when composing messages; ensure references to
getSystemPrompt, processEvents, userId, and uiStream.value are updated
accordingly.
- Around line 102-202: The inner async function processResolutionSearch shadows
the outer groupeId and is invoked fire-and-forget without tethering errors;
rename the inner variable (e.g., localGroupId or resolutionGroupId) wherever
used inside processResolutionSearch to avoid confusion with the outer groupeId,
and change the invocation of processResolutionSearch() to attach a rejection
handler (e.g., processResolutionSearch().catch(err => console.error(...))) or
prefix with void and add .catch to ensure any asynchronous rejection is logged;
apply the same catch-on-invoke pattern used for processEvents() to prevent
unhandled promise rejections.

In `@components/followup-panel.tsx`:
- Around line 19-44: handleSubmit currently doesn't show UI feedback on failure
and allows duplicate submissions; add a local loading state (e.g. isSubmitting)
and an error state (e.g. submitError), check/set isSubmitting at the start of
handleSubmit to early-return if already submitting, set isSubmitting=true before
calling submit(formData), and in the try branch only clear input and push
responseMessage to setMessages on success; in catch set submitError with the
caught error (or a user-friendly message) so the component can render an inline
error or trigger a toast, and in finally set isSubmitting=false; also update the
submit button to be disabled when isSubmitting and show a loading indicator tied
to isSubmitting. Ensure references to handleSubmit, submit, setMessages,
setInput, mapData.drawnFeatures are used to locate the changes.
🪄 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 87ad783b-83a9-40f3-aaf9-f9b05ff954b0

📥 Commits

Reviewing files that changed from the base of the PR and between d0efce4 and 7ab4e93.

📒 Files selected for processing (3)
  • app/actions.tsx
  • components/followup-panel.tsx
  • lib/agents/query-suggestor.tsx
📜 Review details
🔇 Additional comments (1)
components/followup-panel.tsx (1)

22-22: 💤 Low value

No changes required—FollowupPanel routing is correct.

The hardcoded action removal is consistent. FollowupPanel now only appends input and drawnFeatures, correctly routing follow-ups through the regular text path. No other call site depends on FollowupPanel triggering the resolution-search branch—header-search-button and resolution-carousel handle their own action='resolution_search' submissions separately. Follow-ups after a resolution_search_result message route correctly through the regular flow with prior results properly filtered from the AI context (line 310–315).

Comment thread app/actions.tsx
Comment on lines +413 to +430
if (action.object.next === 'inquire') {
const inquiry = await inquire(uiStream, messages)
uiStream.done()
isGenerating.done()
isCollapsed.done(false)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: nanoid(),
role: 'assistant',
content: `inquiry: ${inquiry?.question}`
}
]
})
return
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify how the AI SDK ai/rsc streamables behave on double done() — locate the implementation in node_modules-style paths if vendored, or look for prior usage in the repo.
rg -nP --type=ts -C2 '\b(uiStream|isGenerating|isCollapsed)\.done\b'

Repository: QueueLab/QCX

Length of output: 1818


🏁 Script executed:

sed -n '380,545p' app/actions.tsx | cat -n

Repository: QueueLab/QCX

Length of output: 5403


🌐 Web query:

ai/rsc createStreamableUI done() multiple calls double behavior

💡 Result:

Calling done() multiple times on a createStreamableUI instance from Vercel AI SDK (ai/rsc) will cause doubled or unexpected behavior. The official documentation states that done() "Marks the UI node as finalized and closes the stream. Once called, the UI node cannot be updated or appended anymore."[1][2][3][4] This implies the stream enters a closed state after the first call, making subsequent calls or operations invalid. No sources explicitly confirm if done() is idempotent (safe to call multiple times without side effects), but related GitHub issues show errors from post-done() operations, such as ".update: UI stream is already closed" in parallel tool calls[5], and multiple mounts/rerenders on update/done[6]. Issue #2183 reports that after done(), returning ui.value causes remounting and layout shifts, interpreted as "double behavior" (UI appears twice or flickers)[7]. This double behavior often stems from: - React Strict Mode double-invoking server actions/effects[8]. - Calling done() then inadvertently returning ui.value again[7]. - Nested/multiple streamables where child .done() calls affect parents[9]. - Not using async IIFEs properly, leading to race conditions[1]. To avoid: Call done() exactly once per instance, typically in a finally block or after all updates/appends. Use guards like if (!stream.done) if needed (though not officially documented). Always return ui.value immediately after creation, letting async blocks handle updates/done[10][11]. Example safe usage: const ui = createStreamableUI(<div>Loading...</div>); (async () => { // updates/appends ui.done(<div>Final UI</div>); })(); return ui.value; // Return once, before done() [1][2][10][11]

Citations:


Remove premature stream finalization in the inquire branch to prevent double done() calls.

The 'inquire' branch (lines 415–416) calls uiStream.done() and isGenerating.done(), then returns. However, control still reaches the finally block (lines 537–538), which calls these methods again. According to the Vercel AI SDK documentation, done() finalizes and closes the stream, making it impossible to update or append afterwards. Calling done() a second time on an already-closed stream will fail.

Remove the done() calls from the inquire branch and let the finally block handle finalization:

Proposed fix
       if (action.object.next === 'inquire') {
         const inquiry = await inquire(uiStream, messages)
-        uiStream.done()
-        isGenerating.done()
         isCollapsed.done(false)
         aiState.done({
           ...aiState.get(),
           messages: [
             ...aiState.get().messages,
             {
               id: nanoid(),
               role: 'assistant',
               content: `inquiry: ${inquiry?.question}`
             }
           ]
         })
         return
       }

The finally block already handles isGenerating.done(false) and uiStream.done(), which will execute for the inquire path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 413 - 430, The inquire branch prematurely
finalizes streams: remove the explicit uiStream.done() and isGenerating.done()
calls inside the if (action.object.next === 'inquire') block (leaving
isCollapsed.done(false) and the aiState update) so the finally block's cleanup
handles finalization; specifically, update the code around the inquire(...) call
and return to not call uiStream.done() or isGenerating.done() there and rely on
the existing finally that calls isGenerating.done(false) and uiStream.done().

Comment thread app/actions.tsx
Comment on lines +439 to 474
while (
useSpecificAPI
? answer.length === 0
: answer.length === 0 && !errorOccurred
) {
const { fullResponse, hasError, toolResponses } = await researcher(
currentSystemPrompt,
uiStream,
streamText,
messages,
mapProvider,
useSpecificAPI,
drawnFeatures
)
answer = fullResponse
toolOutputs = toolResponses
errorOccurred = hasError

if (toolOutputs.length > 0) {
toolOutputs.map(output => {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'tool',
content: JSON.stringify(output.result),
name: output.toolName,
type: 'tool'
}
]
})
})
})
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Loop continuation logic ignores errorOccurred when useSpecificAPI is true.

The condition useSpecificAPI ? answer.length === 0 : answer.length === 0 && !errorOccurred means that under the useSpecificAPI branch, an erroring researcher call that returns fullResponse === '' will loop indefinitely (or until researcher happens to return non-empty text). There is no max-iteration cap and no backoff. Combined with the new try/catch swallowing errors at the outer level, a persistently failing researcher will keep retrying inside the same request. Recommend adding an explicit retry limit or also breaking on hasError here.

♻️ Proposed bound
+      let attempts = 0
+      const MAX_ATTEMPTS = 3
       while (
         useSpecificAPI
-          ? answer.length === 0
+          ? answer.length === 0 && attempts < MAX_ATTEMPTS
           : answer.length === 0 && !errorOccurred
       ) {
+        attempts++
         const { fullResponse, hasError, toolResponses } = await researcher(
📝 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
while (
useSpecificAPI
? answer.length === 0
: answer.length === 0 && !errorOccurred
) {
const { fullResponse, hasError, toolResponses } = await researcher(
currentSystemPrompt,
uiStream,
streamText,
messages,
mapProvider,
useSpecificAPI,
drawnFeatures
)
answer = fullResponse
toolOutputs = toolResponses
errorOccurred = hasError
if (toolOutputs.length > 0) {
toolOutputs.map(output => {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'tool',
content: JSON.stringify(output.result),
name: output.toolName,
type: 'tool'
}
]
})
})
})
}
}
let attempts = 0
const MAX_ATTEMPTS = 3
while (
useSpecificAPI
? answer.length === 0 && attempts < MAX_ATTEMPTS
: answer.length === 0 && !errorOccurred
) {
attempts++
const { fullResponse, hasError, toolResponses } = await researcher(
currentSystemPrompt,
uiStream,
streamText,
messages,
mapProvider,
useSpecificAPI,
drawnFeatures
)
answer = fullResponse
toolOutputs = toolResponses
errorOccurred = hasError
if (toolOutputs.length > 0) {
toolOutputs.map(output => {
aiState.update({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'tool',
content: JSON.stringify(output.result),
name: output.toolName,
type: 'tool'
}
]
})
})
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 439 - 474, The loop continuation condition
currently ignores errorOccurred when useSpecificAPI is true, causing infinite
retries; update the loop in actions.tsx so it also breaks on hasError and add a
bounded retry counter: introduce maxRetries (e.g., 3) and retryCount, increment
retryCount each iteration, and change the while condition to require
answer.length === 0 && !errorOccurred && retryCount < maxRetries (or include
!errorOccurred regardless of useSpecificAPI), and inside the loop when
researcher returns hasError === true set errorOccurred and break or let the
condition exit; reference the variables and functions answer, errorOccurred,
useSpecificAPI, researcher, and retryCount/maxRetries when applying the fix.

Comment thread app/actions.tsx
Comment on lines +500 to +539
if (!errorOccurred) {
const relatedQueries = await querySuggestor(uiStream, messages)
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
</Section>
)

await new Promise(resolve => setTimeout(resolve, 500))

aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: answer,
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
}

isGenerating.done(false)
uiStream.done()
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: answer,
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
}
} catch (error) {
console.error('Error in processEvents:', error)
} finally {
isGenerating.done(false)
uiStream.done()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Major: aiState is never finalized on the error path, leaving client AI state uncommitted.

aiState.done(...) is only called inside if (!errorOccurred) (Lines 509–532) and on the inquire branch (Line 418). If researcher/writer/querySuggestor throws and execution falls into catch (Line 534) — or if errorOccurred is true after the loop — the function returns from processEvents without ever calling aiState.done(). The user message added at Lines 381–392 then remains as an in-flight aiState.update(...) that is never committed, so onSetAIState will not run, the chat won't be persisted, and any subsequent aiState.get() from the client may not reflect the final state.

isGenerating/uiStream are now correctly closed in finally, but aiState needs the same treatment. Consider committing a minimal terminal state in catch (and after the errorOccurred loop exit) so the client's mutable state is consistent.

🛡️ Suggested direction
-    } catch (error) {
-      console.error('Error in processEvents:', error)
-    } finally {
+    } catch (error) {
+      console.error('Error in processEvents:', error)
+      try {
+        aiState.done({ ...aiState.get() })
+      } catch (e) {
+        // aiState already finalized; ignore
+      }
+    } finally {
       isGenerating.done(false)
       uiStream.done()
     }

You may also want to guard the errorOccurred === true exit from the loop so the user gets some response surface (e.g., an assistant response message indicating failure) rather than a silently abandoned turn.

📝 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
if (!errorOccurred) {
const relatedQueries = await querySuggestor(uiStream, messages)
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
</Section>
)
await new Promise(resolve => setTimeout(resolve, 500))
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: answer,
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
}
isGenerating.done(false)
uiStream.done()
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: answer,
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
}
} catch (error) {
console.error('Error in processEvents:', error)
} finally {
isGenerating.done(false)
uiStream.done()
}
if (!errorOccurred) {
const relatedQueries = await querySuggestor(uiStream, messages)
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
</Section>
)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: answer,
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),
type: 'related'
},
{
id: groupeId,
role: 'assistant',
content: 'followup',
type: 'followup'
}
]
})
}
} catch (error) {
console.error('Error in processEvents:', error)
try {
aiState.done({ ...aiState.get() })
} catch (e) {
// aiState already finalized; ignore
}
} finally {
isGenerating.done(false)
uiStream.done()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 500 - 539, The processEvents flow fails to
finalize aiState when errors occur or when errorOccurred is true; update
processEvents to ensure aiState.done(...) is always called in the error path and
after the loop exit: in the catch block and immediately after detecting
errorOccurred, call aiState.done(...) with a minimal terminal state (e.g., merge
current aiState.get().messages with a committed assistant error/failure message
and set any terminal flags) so the pending user message is committed and
onSetAIState runs; reference aiState.done, aiState.get, errorOccurred, and the
processEvents try/catch/finally to locate where to add these finalizing calls.

Comment on lines +67 to +78
// OPTIMIZATION: Stream updates but batch them to reduce re-render frequency
let lastUpdateTime = Date.now();
const UPDATE_THROTTLE = 100; // Reduced from 200ms to 100ms for better responsiveness

for await (const obj of result.partialObjectStream) {
if (obj && typeof obj === 'object' && 'items' in obj) {
const now = Date.now();
// Only update UI if enough time has passed since last update
if (now - lastUpdateTime > UPDATE_THROTTLE) {
objectStream.update(obj as PartialRelated)
lastUpdateTime = now;
for await (const obj of result.partialObjectStream) {
if (obj && typeof obj === 'object' && 'items' in obj) {
const now = Date.now();
// Only update UI if enough time has passed since last update
if (now - lastUpdateTime > UPDATE_THROTTLE) {
objectStream.update(obj as PartialRelated)
lastUpdateTime = now;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Throttle initialization may suppress the first UI update.

lastUpdateTime is set to Date.now() immediately before the loop, so the very first partial object — which often contains the most useful initial content — is unlikely to satisfy now - lastUpdateTime > UPDATE_THROTTLE and gets skipped from the streaming UI. The finally block masks this for the terminal value, but interactive responsiveness is reduced. Initializing lastUpdateTime = 0 (or always emitting the first chunk) is a one-line fix.

♻️ Proposed fix
-    let lastUpdateTime = Date.now();
+    let lastUpdateTime = 0;
     const UPDATE_THROTTLE = 100; // Reduced from 200ms to 100ms for better responsiveness
📝 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
// OPTIMIZATION: Stream updates but batch them to reduce re-render frequency
let lastUpdateTime = Date.now();
const UPDATE_THROTTLE = 100; // Reduced from 200ms to 100ms for better responsiveness
for await (const obj of result.partialObjectStream) {
if (obj && typeof obj === 'object' && 'items' in obj) {
const now = Date.now();
// Only update UI if enough time has passed since last update
if (now - lastUpdateTime > UPDATE_THROTTLE) {
objectStream.update(obj as PartialRelated)
lastUpdateTime = now;
for await (const obj of result.partialObjectStream) {
if (obj && typeof obj === 'object' && 'items' in obj) {
const now = Date.now();
// Only update UI if enough time has passed since last update
if (now - lastUpdateTime > UPDATE_THROTTLE) {
objectStream.update(obj as PartialRelated)
lastUpdateTime = now;
}
// OPTIMIZATION: Stream updates but batch them to reduce re-render frequency
let lastUpdateTime = 0;
const UPDATE_THROTTLE = 100; // Reduced from 200ms to 100ms for better responsiveness
for await (const obj of result.partialObjectStream) {
if (obj && typeof obj === 'object' && 'items' in obj) {
const now = Date.now();
// Only update UI if enough time has passed since last update
if (now - lastUpdateTime > UPDATE_THROTTLE) {
objectStream.update(obj as PartialRelated)
lastUpdateTime = now;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/agents/query-suggestor.tsx` around lines 67 - 78, The throttle
initialization prevents the first partial chunk from being emitted because
lastUpdateTime is set to Date.now(); change the initialization of lastUpdateTime
(or logic) so the first chunk always emits — e.g., set lastUpdateTime = 0 or add
a first-chunk guard before the for-await loop that calls
objectStream.update(obj) unconditionally; update references in the streaming
loop that check now - lastUpdateTime > UPDATE_THROTTLE and ensure lastUpdateTime
is updated after that first emit so subsequent throttling still applies
(affecting variables lastUpdateTime, UPDATE_THROTTLE, the for-await-of over
result.partialObjectStream, and objectStream.update).

@ngoiyaeric
Copy link
Copy Markdown
Collaborator Author

@coderabbitai the resolution search has no output now

@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

1 similar comment
@CLAassistant
Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== processResolutionSearch in app/actions.tsx ==="
rg -n "processResolutionSearch\|resolution_search\|resolutionSearch" app/actions.tsx -C 5

echo "=== Full processResolutionSearch function ==="
# Find line number where it starts
start=$(rg -n "processResolutionSearch" app/actions.tsx | head -1 | cut -d: -f1)
echo "starts at line: $start"

echo "=== followup-panel routing logic ==="
cat components/followup-panel.tsx

echo "=== Any other files referencing resolution_search ==="
rg -rn "resolution_search\|resolutionSearch\|processResolutionSearch"

Length of output: 2056


🏁 Script executed:

#!/bin/bash
echo "=== Full app/actions.tsx ==="
cat -n app/actions.tsx

echo ""
echo "=== Git diff for app/actions.tsx on this PR branch vs main ==="
git log --oneline -5
git diff HEAD~2 HEAD -- app/actions.tsx | head -300

Length of output: 39396


🏁 Script executed:

#!/bin/bash
echo "=== Search for components calling submit with resolution_search or file uploads ==="
rg -rn "resolution.search\|resolution_search\|resolutionSearch" --type-add 'web:*.{ts,tsx,js,jsx}' -t web

echo "---"
echo "=== Search for submit() calls with file or action in formData ==="
rg -rn "formData.*append\|append.*action\|append.*file\|append.*resolution" --type-add 'web:*.{ts,tsx,js,jsx}' -t web -C 2

echo "---"
echo "=== Find the resolutionSearch agent ==="
fd -e ts -e tsx "resolution" 

echo "---"
echo "=== List all files in lib/agents ==="
ls lib/agents/

echo "---"
echo "=== ResolutionSearch agent content ==="
fd "resolution" lib/ --exec cat {}

echo "---"
echo "=== Components that render ResolutionCarousel ==="
rg -rn "ResolutionCarousel\|ResolutionImage\|resolution-carousel\|resolution-image" --type-add 'web:*.{ts,tsx,js,jsx}' -t web -C 2

Length of output: 7974


🏁 Script executed:

#!/bin/bash
echo "=== Search for map panel / camera / screenshot / capture components ==="
fd -e tsx -e ts "camera\|capture\|screenshot\|panel\|map" components/ 

echo "---"
echo "=== List all map-related components ==="
ls components/map/ 2>/dev/null || echo "No map subdirectory"

echo "---"
echo "=== Search for any submit() call with formData in components ==="
rg -rn "submit\(form\|submit\(new Form\|useActions" components/ -C 3

echo "---"
echo "=== Search for file_mapbox or file_google references anywhere ==="
rg -rn "file_mapbox\|file_google\|mapboxImage\|googleImage" -C 3

echo "---"
echo "=== Check the map panel component if it exists ==="
fd "map-panel\|map-capture\|map-camera\|search-panel\|chat-panel" -e tsx -e ts
cat components/map/*.tsx 2>/dev/null | head -300

Length of output: 8586


🏁 Script executed:

#!/bin/bash
echo "=== mapbox-map.tsx ==="
cat components/map/mapbox-map.tsx

echo "---"
echo "=== Check main search/chat form for file upload / action submission ==="
fd -e tsx -e ts "search\|chat\|form\|header" components/ | grep -v map | head -20

echo "---"
echo "=== List all tsx files in components root ==="
ls components/*.tsx

echo "---"
echo "=== Check search panel or main form for resolution_search trigger ==="
rg -n "resolution\|mapCapture\|captureMap\|map.*capture\|screenshot\|toBlob\|toDataURL\|getCanvas" components/ -C 3

Length of output: 23061


🏁 Script executed:

#!/bin/bash
echo "=== Full git diff for followup-panel.tsx ==="
git diff HEAD~2 HEAD -- components/followup-panel.tsx

echo "---"
echo "=== chat-panel.tsx contents ==="
cat components/chat-panel.tsx

echo "---"
echo "=== mobile-icons-bar.tsx ==="
cat components/mobile-icons-bar.tsx

Length of output: 13874


@ngoiyaeric Here's the full diagnosis and a plan to fix it properly.


🔍 Root Cause

The PR removed formData.append("action", "resolution_search") from FollowupPanel — and that was the only line anywhere in the codebase that ever routed to the resolution search path. No other component triggers it.

However, even before this PR, that trigger was already broken: FollowupPanel never provided any image files, and app/actions.tsx line 62–64 immediately throws if no file is provided:

if (!file) {
  throw new Error('No file provided for resolution search.');
}

So the old FollowupPanel → resolution search path always threw an error. What's actually needed is a dedicated map capture button that:

  1. Captures the current Mapbox canvas (enabled by preserveDrawingBuffer: true already set in mapbox-map.tsx)
  2. Submits with action: 'resolution_search' + the image blob + lat/lng/timezone from context

All the needed context is already available:

  • useMap() → the mapboxgl.Map instance → map.getCanvas().toBlob(...)
  • useMapData()mapData.cameraState (lat/lng), mapData.currentTimezone, mapData.drawnFeatures
  • useActions()submit()

🛠 Proposed Fix

  • Create components/map-analysis-button.tsx — a button that captures the Mapbox canvas and submits it as a resolution search
  • Integrate it into components/chat-panel.tsx or components/mobile-icons-bar.tsx so it's accessible alongside the chat input

🏁 Script executed:

#!/bin/bash
# Verify the map-loading-context hook to confirm availability check
cat components/map-loading-context.tsx
# Verify useMap hook export
head -30 components/map/map-context.tsx

Length of output: 1588


@ngoiyaeric Here's the full diagnosis and fix plan.


🔍 Root Cause

The PR removed this single line from FollowupPanel:

- formData.append("action", "resolution_search")

That was the only line in the entire codebase that ever routed to the resolution search path in app/actions.tsx. After removal, nothing triggers it.

Importantly, even before this PR, that trigger was already broken: FollowupPanel never sent any image files, and app/actions.tsx (Line 62) always throws immediately if none is provided:

if (!file) {
  throw new Error('No file provided for resolution search.');
}

What's actually needed is a dedicated "Analyze Map" button that properly captures the current map view. All the pieces are already in place:

What's needed What's available
Mapbox canvas capture preserveDrawingBuffer: true already set in mapbox-map.tsxmap.getCanvas().toBlob(...)
Map instance useMap() from components/map/map-context.tsx
Lat/lng + timezone useMapData()mapData.cameraState, mapData.currentTimezone
Drawn features useMapData()mapData.drawnFeatures
Submit action useActions()submit()

🛠 Proposed Fix

  • Create components/map-analysis-button.tsx — a button that captures the Mapbox canvas and submits it as a resolution search
  • Integrate it into components/chat-panel.tsx (desktop) and components/mobile-icons-bar.tsx (mobile)

🏁 Script executed:

#!/bin/bash
# Verify the map-loading-context and map-context exports for availability checks
cat components/map-loading-context.tsx
head -40 components/map/map-context.tsx

Length of output: 1588


@ngoiyaeric Here are the complete code changes to restore resolution search with a proper map capture trigger.


1. New file: components/map-analysis-button.tsx

'use client'

import { useState } from 'react'
import { useMap } from './map/map-context'
import { useMapData } from './map/map-data-context'
import { useMapLoading } from './map-loading-context'
import { useActions, useUIState } from 'ai/rsc'
import type { AI } from '@/app/actions'
import { Button } from './ui/button'
import { ScanSearch } from 'lucide-react'
import { nanoid } from '@/lib/utils'
import { UserMessage } from './user-message'

export function MapAnalysisButton({ size = 20 }: { size?: number }) {
  const { map } = useMap()
  const { mapData } = useMapData()
  const { isMapLoaded } = useMapLoading()
  const { submit } = useActions()
  const [, setMessages] = useUIState<typeof AI>()
  const [isLoading, setIsLoading] = useState(false)

  if (!isMapLoaded || !map) return null

  const handleAnalyze = () => {
    map.getCanvas().toBlob(async blob => {
      if (!blob) return
      setIsLoading(true)
      try {
        const formData = new FormData()
        formData.append('action', 'resolution_search')
        formData.append(
          'file_mapbox',
          new File([blob], 'mapbox-capture.png', { type: 'image/png' })
        )
        const lat = mapData.cameraState?.center?.lat
        const lng = mapData.cameraState?.center?.lng
        if (lat !== undefined) formData.append('latitude', String(lat))
        if (lng !== undefined) formData.append('longitude', String(lng))
        formData.append('timezone', mapData.currentTimezone || 'UTC')
        formData.append(
          'drawnFeatures',
          JSON.stringify(mapData.drawnFeatures || [])
        )

        const userMessage = {
          id: nanoid(),
          isGenerating: false,
          component: <UserMessage content="Analyze current map view" />
        }

        const responseMessage = await submit(formData)
        setMessages(currentMessages => [
          ...currentMessages,
          userMessage,
          responseMessage as any
        ])
      } catch (error) {
        console.error('Error submitting map analysis:', error)
      } finally {
        setIsLoading(false)
      }
    }, 'image/png')
  }

  return (
    <Button
      type="button"
      variant="ghost"
      size="icon"
      onClick={handleAnalyze}
      disabled={isLoading}
      title="Analyze current map view"
      data-testid="map-analysis-button"
    >
      <ScanSearch size={size} className={isLoading ? 'animate-pulse' : ''} />
    </Button>
  )
}

2. components/chat-panel.tsx — add button next to the desktop paperclip

+ import { MapAnalysisButton } from './map-analysis-button'

  // In the desktop input area, after the existing Paperclip button:
  {!isMobile && (
    <Button
      type="button"
      variant={'ghost'}
      size={'icon'}
      className={cn(
        'absolute top-1/2 transform -translate-y-1/2 left-3'
      )}
      onClick={handleAttachmentClick}
      data-testid="desktop-attachment-button"
    >
      <Paperclip size={isMobile ? 18 : 20} />
    </Button>
  )}
+ {!isMobile && (
+   <div className="absolute top-1/2 transform -translate-y-1/2 left-10">
+     <MapAnalysisButton size={20} />
+   </div>
+ )}

Also update the pl-14 on the Textarea to pl-20 to accommodate both buttons.


3. components/mobile-icons-bar.tsx — add button between paperclip and submit

+ import { MapAnalysisButton } from './map-analysis-button'

  <Button variant="ghost" size="icon" onClick={onAttachmentClick} data-testid="mobile-attachment-button">
    <Paperclip className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
  </Button>
+ <MapAnalysisButton size={19} />
  <Button variant="ghost" size="icon" data-testid="mobile-submit-button" onClick={onSubmitClick}>
    <ArrowRight className="h-[1.2rem] w-[1.2rem] transition-all rotate-0 scale-100" />
  </Button>

Why This Works

  • preserveDrawingBuffer: true is already set in mapbox-map.tsx, so map.getCanvas().toBlob() works without any map config changes.
  • The button is hidden (return null) when the map isn't loaded — no errors in non-map contexts.
  • The action: 'resolution_search' + file_mapbox fields satisfy the existing checks in app/actions.tsx lines 53–64 exactly as expected.
  • Lat/lng and timezone come from mapData.cameraState and mapData.currentTimezone which are already kept in sync with map movement by mapbox-map.tsx's captureMapCenter callback.

The requested changes have been implemented and a pull request has been created: View PR

@google-labs-jules
Copy link
Copy Markdown
Contributor

@coderabbitai the resolution search has no output now

I've fixed the issue where resolution search had no output. The problem was that the UI update call was being made outside the asynchronous handler, sometimes missing the initial state. I've moved it inside and added safety delays to ensure the stream flushes correctly.

Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
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.

2 participants