Skip to content

fix: resolve chat input lag during active SSE streaming#490

Open
TheDarkSkyXD wants to merge 1 commit intospacedriveapp:mainfrom
TheDarkSkyXD:fix/chat-input-lag
Open

fix: resolve chat input lag during active SSE streaming#490
TheDarkSkyXD wants to merge 1 commit intospacedriveapp:mainfrom
TheDarkSkyXD:fix/chat-input-lag

Conversation

@TheDarkSkyXD
Copy link
Contributor

@TheDarkSkyXD TheDarkSkyXD commented Mar 25, 2026

Summary

  • Convert FloatingChatInput and CortexChatInput from controlled (useState) to uncontrolled (ref-based) textareas so keystrokes bypass React's render cycle entirely
  • Wrap both input components in React.memo to prevent re-renders when parent state changes from SSE events
  • Extract MessageList and CortexMessageList into React.memo components so long chat histories don't re-render on every SSE event

Problem

When chatting with an agent over a long conversation, typing in the input box was severely laggy — letters would take seconds to appear, and backspace was equally slow. This was caused by the shared SSE event bus firing frequent state updates (worker lifecycle, tool calls, streaming tokens), which triggered React re-renders of the entire chat component tree including the controlled textarea input. With hundreds of messages to reconcile, the browser couldn't process keystrokes in time.

Test plan

  • Tested with long chat history — typing is now instant and fluid
  • Verify submit still works via Enter key and send button
  • Verify textarea auto-height adjustment still works for multi-line input
  • Verify Cortex chat starter prompts still work
  • Verify chat still auto-scrolls on new messages

🤖 Generated with Claude Code

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 25, 2026

Walkthrough

Both CortexChatPanel and WebChatPanel refactored their input components from controlled (value/onChange props) to uncontrolled (internal textarea management), changed submit callbacks to receive text directly, extracted message rendering into separate memoized components, and removed local input state from parent handlers.

Changes

Cohort / File(s) Summary
Input Component Refactoring
interface/src/components/CortexChatPanel.tsx, interface/src/components/WebChatPanel.tsx
Converted CortexChatInput and FloatingChatInput from controlled to uncontrolled components. Changed onSubmit prop signature from () => void to (text: string) => void. Added internal hasText state and onInput handlers to track textarea emptiness for submit button disabling. Updated submission flow to trim, clear textarea, and pass text to handler.
Message List Extraction
interface/src/components/CortexChatPanel.tsx, interface/src/components/WebChatPanel.tsx
Extracted message rendering logic into new memoized components: CortexMessageList (renders user messages vs assistant markdown/tool calls) and MessageList (renders timeline items with typing indicator and error handling). Parent components now pass messages/timeline via props instead of inline rendering.
Parent Component Simplification
interface/src/components/CortexChatPanel.tsx, interface/src/components/WebChatPanel.tsx
Removed local input state (useState("")) from both CortexChatPanel and WebChatPanel. Updated handleSubmit handlers to useCallback that directly forward received text to sendMessage, eliminating empty/trimming checks at parent level. Input validation now delegated to child components via disabled prop gating.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: resolve chat input lag during active SSE streaming' directly and clearly describes the main change: addressing input latency caused by SSE events, which aligns with the primary objective of converting controlled to uncontrolled textareas and memoizing components.
Description check ✅ Passed The pull request description clearly explains the problem (chat input lag during SSE streaming), the solution (uncontrolled textareas and React.memo), and includes a test plan.

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

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

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

❤️ Share

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

Convert WebChatPanel and CortexChatPanel text inputs from controlled
(useState) to uncontrolled (ref-based) textareas so keystrokes bypass
React's render cycle entirely. Wrap input components and message lists
in React.memo to prevent expensive re-renders of long chat histories
on every SSE event.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
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: 7

Caution

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

⚠️ Outside diff range comments (1)
src/config/types.rs (1)

840-846: ⚠️ Potential issue | 🟠 Major

Keep JavaScript evaluation opt-in by default.

Line 845 changes evaluate_enabled to true, which enables browser_evaluate for any agent that doesn’t explicitly override config (via DefaultsConfig + unwrap_or(base.evaluate_enabled) merge). That weakens least-privilege defaults and expands prompt-injection blast radius.

🔧 Suggested fix
 impl Default for BrowserConfig {
     fn default() -> Self {
         Self {
             enabled: true,
             headless: true,
-            evaluate_enabled: true,
+            evaluate_enabled: false,
             executable_path: None,
             screenshot_dir: None,
             persist_session: false,
             close_policy: ClosePolicy::default(),
             chrome_cache_dir: PathBuf::from("chrome_cache"),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config/types.rs` around lines 840 - 846, The default for BrowserConfig
enables JavaScript evaluation by setting evaluate_enabled to true; revert this
to keep JS evaluation opt-in by default by changing BrowserConfig::default so
evaluate_enabled is false and ensure any existing merge/override logic that uses
DefaultsConfig and unwrap_or(base.evaluate_enabled) continues to respect
explicit per-agent settings rather than flipping to true implicitly—update the
BrowserConfig::default implementation (symbol: BrowserConfig, field:
evaluate_enabled) accordingly.
🧹 Nitpick comments (5)
interface/src/components/CortexChatPanel.tsx (1)

181-189: Consider extracting adjustHeight into a shared utility.

Both FloatingChatInput (WebChatPanel.tsx lines 108-116) and CortexChatInput (lines 181-189) have nearly identical adjustHeight implementations, differing only in maxHeight (200 vs 160). This could be extracted into a shared utility or custom hook.

✏️ Optional extraction example

Create a shared utility:

// utils/textarea.ts
export function adjustTextareaHeight(textarea: HTMLTextAreaElement, maxHeight: number) {
  textarea.style.height = "auto";
  const scrollHeight = textarea.scrollHeight;
  textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`;
  textarea.style.overflowY = scrollHeight > maxHeight ? "auto" : "hidden";
}

Then use in both components:

const adjustHeight = () => {
  if (textareaRef.current) {
    adjustTextareaHeight(textareaRef.current, 160); // or 200
  }
};

Also applies to: 108-116

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

In `@interface/src/components/CortexChatPanel.tsx` around lines 181 - 189, Both
CortexChatInput's adjustHeight (in CortexChatPanel.tsx) and FloatingChatInput's
adjustHeight (in WebChatPanel.tsx) duplicate textarea resizing logic; extract
that logic into a shared utility (e.g., export function
adjustTextareaHeight(textarea: HTMLTextAreaElement, maxHeight: number) in
utils/textarea.ts or a custom hook useAutoResizeTextarea) and replace each
component's adjustHeight with a thin wrapper that calls the shared function
passing the appropriate maxHeight (160 for CortexChatInput, 200 for
FloatingChatInput); update imports and ensure textareaRef.current null checks
remain in the components.
interface/src/components/WebChatPanel.tsx (1)

194-194: Consider using React.RefObject<HTMLDivElement> without | null for cleaner typing.

In React 19, RefObject already allows null for the current property. The type React.RefObject<HTMLDivElement | null> is slightly redundant, though it still works correctly.

✏️ Optional type simplification
-	messagesEndRef: React.RefObject<HTMLDivElement | null>;
+	messagesEndRef: React.RefObject<HTMLDivElement>;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/WebChatPanel.tsx` at line 194, The property
messagesEndRef is typed redundantly as React.RefObject<HTMLDivElement | null>;
update its declaration to React.RefObject<HTMLDivElement> to rely on RefObject's
built-in nullable current type (locate the messagesEndRef declaration in the
WebChatPanel component and replace its type accordingly), ensuring any places
that access messagesEndRef.current remain type-safe and adjust any explicit null
assertions if present.
scripts/bundle-sidecar.ps1 (2)

20-22: Potential failure if rustc is not available or output parsing fails.

If rustc is not installed or the Select-String returns nothing, the script will fail with an unclear error. Consider adding validation.

🛡️ Proposed defensive check
 # Determine Rust target triple
+$RustcOutput = rustc -vV
+if ($LASTEXITCODE -ne 0) {
+    Write-Error "Failed to run rustc. Ensure Rust is installed."
+    exit 1
+}
-$HostTriple = (rustc -vV | Select-String "^host:").ToString().Split(" ")[1]
+$HostLine = $RustcOutput | Select-String "^host:"
+if (-not $HostLine) {
+    Write-Error "Could not determine Rust host triple from rustc output."
+    exit 1
+}
+$HostTriple = $HostLine.ToString().Split(" ")[1]
 $TargetTriple = if ($env:TAURI_ENV_TARGET_TRIPLE) { $env:TAURI_ENV_TARGET_TRIPLE } else { $HostTriple }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/bundle-sidecar.ps1` around lines 20 - 22, The script assumes rustc
exists and that Select-String returns a match when computing $HostTriple, which
can fail silently; add defensive checks: first verify rustc is in PATH (e.g.,
test running "rustc -vV" and capture non-zero/empty output) and if it fails emit
a clear error and exit, then ensure Select-String produced a match before
accessing Split — if no match, emit a descriptive error or fall back to a
sensible default; finally keep the existing $TargetTriple assignment but only
use the parsed $HostTriple when it was successfully validated. Refer to the
symbols $HostTriple, $TargetTriple, rustc and Select-String when locating where
to add these validations.

43-52: PSScriptAnalyzer flags Write-Host usage — acceptable for build scripts.

The static analysis tool warns against Write-Host because it bypasses output streams. However, for interactive build scripts intended for developer use, Write-Host is appropriate for progress messages. You can suppress this warning or optionally switch to Write-Information if you want strict compliance.

♻️ Optional: Use Write-Information instead
-Write-Host "Building spacebot ($BuildMode) for $TargetTriple..."
+Write-Information "Building spacebot ($BuildMode) for $TargetTriple..." -InformationAction Continue
 ...
-Write-Host "Copied $SrcBin -> $DestBin"
-Write-Host "Sidecar binary ready."
+Write-Information "Copied $SrcBin -> $DestBin" -InformationAction Continue
+Write-Information "Sidecar binary ready." -InformationAction Continue

Or add a suppression comment at the top of the script:

`#Requires` -Version 5.1
# PSScriptAnalyzer: disable PSAvoidUsingWriteHost
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/bundle-sidecar.ps1` around lines 43 - 52, PSScriptAnalyzer flags the
use of Write-Host in the bundle-sidecar script (occurrences of Write-Host
"Copied $SrcBin -> $DestBin" and Write-Host "Sidecar binary ready."), so either
replace those Write-Host calls with Write-Information to route messages through
the standard output streams or add a PSScriptAnalyzer suppression at the top of
the script to disable PSAvoidUsingWriteHost (e.g., add a comment to disable
PSAvoidUsingWriteHost or the suggested Requires header); choose one approach and
apply it consistently for the Write-Host usages in this script.
interface/src/components/ModelSelect.tsx (1)

126-140: Precompute the flat-index lookup.

Every row does flatList.indexOf(model) during render. Since each highlight change rerenders the whole list, this makes navigation O(n²). A memoized id→index map keeps the hot path linear.

Suggested refactor
   const flatList = useMemo(() => {
     const items: ModelInfo[] = [];
     for (const p of sortedProviders) {
       for (const m of grouped[p]) {
         items.push(m);
       }
     }
     return items;
   }, [sortedProviders, grouped]);
+
+  const flatIndexById = useMemo(
+    () => new Map(flatList.map((model, index) => [model.id, index])),
+    [flatList],
+  );
 
   // Find display name for current value
   const selectedModel = useMemo(
     () => models.find((m) => m.id === value),
     [models, value],
@@
                   {grouped[prov].map((model) => {
-                    const flatIdx = flatList.indexOf(model);
+                    const flatIdx = flatIndexById.get(model.id) ?? -1;
                     const isHighlighted = flatIdx === highlightIndex;
                     const isSelected = model.id === value;

Also applies to: 314-315

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

In `@interface/src/components/ModelSelect.tsx` around lines 126 - 140, The current
flatList useMemo builds an array (flatList) and render code repeatedly calls
flatList.indexOf(model), causing O(n²) rerenders; create a memoized map from
model id to index (e.g., idToIndex) using useMemo alongside flatList (dependent
on sortedProviders and grouped) and replace all flatList.indexOf(model) calls
with idToIndex[model.id] (also apply the same change where models/value are used
at lines referenced around selectedModel and the other occurrence at 314-315);
ensure the map is kept in sync by computing it from the same flatList source so
lookups become O(1).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@interface/src/components/ModelSelect.tsx`:
- Around line 318-333: The option buttons currently call handleSelect(model.id)
only from the onMouseDown handler which prevents keyboard activation; keep the
existing onMouseDown behavior to suppress blur but remove the call to
handleSelect from onMouseDown and instead invoke handleSelect(model.id) from an
onClick handler on the same button element; update the button that uses
setHighlightIndex and onMouseEnter to add onClick={() => handleSelect(model.id)}
and ensure onMouseDown only calls e.preventDefault() to preserve blur
suppression.
- Around line 209-214: The ArrowUp key branch in the key handler (in
ModelSelect.tsx) only updates setHighlightIndex using flatList and doesn't open
the menu when the input is closed; mirror the ArrowDown behavior by checking the
closed state and calling the same open/menu-open path (the same function or
state toggle used by the ArrowDown branch) before updating setHighlightIndex so
the first ArrowUp press opens the menu and moves the highlight as expected.
Ensure you reference the same open/menu toggle used elsewhere in this component
so behavior is consistent with the ArrowDown branch.

In `@scripts/bundle-sidecar.ps1`:
- Around line 35-41: After each external cargo build invocation (the two
branches that run "cargo build" when TargetTriple vs HostTriple are compared),
check $LASTEXITCODE immediately and abort if non-zero; in practice, after the
cargo build calls referenced by CargoFlags, TargetTriple, HostTriple, BuildMode
and RepoRoot, inspect $LASTEXITCODE and call exit $LASTEXITCODE or throw an
error before continuing to set $SrcBin or perform any copy so the script does
not proceed when the build failed.

In `@src/agent/compactor.rs`:
- Around line 175-192: The boundary scan can miss the case when remove_count ==
total.saturating_sub(2), leaving an orphaned User ToolResult; update the loop in
compactor.rs that advances remove_count (the one using history.get(remove_count)
and matching Message::User / UserContent::ToolResult) to include the equality
case (e.g., use <= or remove the strict < bound) and, when the scan reaches
exactly total.saturating_sub(2), handle it by splitting the boundary User
message instead of keeping it whole (i.e., trim or split the User content so
ToolResult is not orphaned) — apply the same change to the analogous scan block
referenced around lines 226-247.

In `@src/agent/worker.rs`:
- Around line 749-770: The overflow-recovery loop can still leave an orphaned
User ToolResult when remove_count equals total.saturating_sub(2); change the
loop bounds so the scan includes that index. Specifically, adjust the loop
condition around remove_count/total (and the initial clamping if needed) so the
while allows checking when remove_count == total.saturating_sub(2) (e.g., use <=
instead of < or clamp differently) and keep the existing logic that increments
remove_count when history.get(remove_count) matches rig::message::Message::User
with UserContent::ToolResult; this mirrors the channel compactor fix so the
paired ToolResult is advanced past the removal boundary.

In `@src/api/models.rs`:
- Around line 341-345: The new branches that push "ollama", "nvidia", and
"github-copilot" into providers are never translated by direct_provider_mapping,
so catalog lookups fall back to "openrouter" and filters like
configured.contains(...) or provider=<name> fail; update direct_provider_mapping
to emit the corresponding provider IDs for Ollama, NVIDIA, and GitHub Copilot
(or add a mapping helper used by direct_provider_mapping) so that provider
strings produced by providers.push("ollama"/"nvidia"/"github-copilot") match
catalog entries and the configured.contains(...) / provider=<name> filters.
Ensure the mapping is symmetric with has_key(...) usage and update any tests or
usages of direct_provider_mapping to include the new IDs.

In `@src/tools/browser.rs`:
- Around line 2057-2067: CloseBrowser currently swallows all errors from
browser.close() while CloseTabs collects and returns errors; change CloseBrowser
to mirror CloseTabs by distinguishing benign disconnection/session-timeout
errors (log as warn and treat as closed) from real close failures: inspect the
error returned by browser.close(), map known benign patterns (e.g., "oneshot
canceled", websocket disconnect/session timeout) to warnings and continue, but
for other errors collect/return Err (propagate an error result) instead of
silently succeeding; update the CloseBrowser control flow around
browser.close().await and the tracing::warn call to follow the same
error-collection/return behavior used in CloseTabs.

---

Outside diff comments:
In `@src/config/types.rs`:
- Around line 840-846: The default for BrowserConfig enables JavaScript
evaluation by setting evaluate_enabled to true; revert this to keep JS
evaluation opt-in by default by changing BrowserConfig::default so
evaluate_enabled is false and ensure any existing merge/override logic that uses
DefaultsConfig and unwrap_or(base.evaluate_enabled) continues to respect
explicit per-agent settings rather than flipping to true implicitly—update the
BrowserConfig::default implementation (symbol: BrowserConfig, field:
evaluate_enabled) accordingly.

---

Nitpick comments:
In `@interface/src/components/CortexChatPanel.tsx`:
- Around line 181-189: Both CortexChatInput's adjustHeight (in
CortexChatPanel.tsx) and FloatingChatInput's adjustHeight (in WebChatPanel.tsx)
duplicate textarea resizing logic; extract that logic into a shared utility
(e.g., export function adjustTextareaHeight(textarea: HTMLTextAreaElement,
maxHeight: number) in utils/textarea.ts or a custom hook useAutoResizeTextarea)
and replace each component's adjustHeight with a thin wrapper that calls the
shared function passing the appropriate maxHeight (160 for CortexChatInput, 200
for FloatingChatInput); update imports and ensure textareaRef.current null
checks remain in the components.

In `@interface/src/components/ModelSelect.tsx`:
- Around line 126-140: The current flatList useMemo builds an array (flatList)
and render code repeatedly calls flatList.indexOf(model), causing O(n²)
rerenders; create a memoized map from model id to index (e.g., idToIndex) using
useMemo alongside flatList (dependent on sortedProviders and grouped) and
replace all flatList.indexOf(model) calls with idToIndex[model.id] (also apply
the same change where models/value are used at lines referenced around
selectedModel and the other occurrence at 314-315); ensure the map is kept in
sync by computing it from the same flatList source so lookups become O(1).

In `@interface/src/components/WebChatPanel.tsx`:
- Line 194: The property messagesEndRef is typed redundantly as
React.RefObject<HTMLDivElement | null>; update its declaration to
React.RefObject<HTMLDivElement> to rely on RefObject's built-in nullable current
type (locate the messagesEndRef declaration in the WebChatPanel component and
replace its type accordingly), ensuring any places that access
messagesEndRef.current remain type-safe and adjust any explicit null assertions
if present.

In `@scripts/bundle-sidecar.ps1`:
- Around line 20-22: The script assumes rustc exists and that Select-String
returns a match when computing $HostTriple, which can fail silently; add
defensive checks: first verify rustc is in PATH (e.g., test running "rustc -vV"
and capture non-zero/empty output) and if it fails emit a clear error and exit,
then ensure Select-String produced a match before accessing Split — if no match,
emit a descriptive error or fall back to a sensible default; finally keep the
existing $TargetTriple assignment but only use the parsed $HostTriple when it
was successfully validated. Refer to the symbols $HostTriple, $TargetTriple,
rustc and Select-String when locating where to add these validations.
- Around line 43-52: PSScriptAnalyzer flags the use of Write-Host in the
bundle-sidecar script (occurrences of Write-Host "Copied $SrcBin -> $DestBin"
and Write-Host "Sidecar binary ready."), so either replace those Write-Host
calls with Write-Information to route messages through the standard output
streams or add a PSScriptAnalyzer suppression at the top of the script to
disable PSAvoidUsingWriteHost (e.g., add a comment to disable
PSAvoidUsingWriteHost or the suggested Requires header); choose one approach and
apply it consistently for the Write-Host usages in this script.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53a80cdc-8433-4164-ab3e-d0f5e1d01d21

📥 Commits

Reviewing files that changed from the base of the PR and between b7d5dd2 and 2307d34.

⛔ Files ignored due to path filters (50)
  • desktop/src-tauri/gen/schemas/windows-schema.json is excluded by !**/gen/**, !**/*.json, !**/gen/**
  • desktop/src-tauri/icons/64x64.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square107x107Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square142x142Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square150x150Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square284x284Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square30x30Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square310x310Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square44x44Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square71x71Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/Square89x89Logo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/StoreLogo.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml is excluded by !**/*.xml
  • desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/android/values/ic_launcher_background.xml is excluded by !**/*.xml
  • desktop/src-tauri/icons/icon.ico is excluded by !**/*.ico, !**/*.ico
  • desktop/src-tauri/icons/icon.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-512@2x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png is excluded by !**/*.png, !**/*.png
  • desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png is excluded by !**/*.png, !**/*.png
  • desktop/tauri.windows.conf.json is excluded by !**/*.json
📒 Files selected for processing (9)
  • interface/src/components/CortexChatPanel.tsx
  • interface/src/components/ModelSelect.tsx
  • interface/src/components/WebChatPanel.tsx
  • scripts/bundle-sidecar.ps1
  • src/agent/compactor.rs
  • src/agent/worker.rs
  • src/api/models.rs
  • src/config/types.rs
  • src/tools/browser.rs

Comment on lines +209 to +214
if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((prev) =>
prev > 0 ? prev - 1 : flatList.length - 1,
);
return;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Mirror the closed-state setup for ArrowUp.

When the input is closed, this branch only updates highlightIndex, so the first ArrowUp press appears to do nothing. Open the menu here the same way the ArrowDown path does.

Suggested fix
     if (e.key === "ArrowUp") {
       e.preventDefault();
+      if (!open) {
+        setOpen(true);
+        setFilter(value);
+      }
       setHighlightIndex((prev) =>
         prev > 0 ? prev - 1 : flatList.length - 1,
       );
       return;
     }
📝 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 (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIndex((prev) =>
prev > 0 ? prev - 1 : flatList.length - 1,
);
return;
if (e.key === "ArrowUp") {
e.preventDefault();
if (!open) {
setOpen(true);
setFilter(value);
}
setHighlightIndex((prev) =>
prev > 0 ? prev - 1 : flatList.length - 1,
);
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/ModelSelect.tsx` around lines 209 - 214, The ArrowUp
key branch in the key handler (in ModelSelect.tsx) only updates
setHighlightIndex using flatList and doesn't open the menu when the input is
closed; mirror the ArrowDown behavior by checking the closed state and calling
the same open/menu-open path (the same function or state toggle used by the
ArrowDown branch) before updating setHighlightIndex so the first ArrowUp press
opens the menu and moves the highlight as expected. Ensure you reference the
same open/menu toggle used elsewhere in this component so behavior is consistent
with the ArrowDown branch.

Comment on lines +318 to +333
<button
key={model.id}
type="button"
data-model-item
className={`w-full text-left px-3 py-1.5 text-sm transition-colors flex items-center justify-between gap-2 ${
isHighlighted
? "bg-accent/20 text-ink"
: isSelected
? "bg-app-selected/50 text-ink"
: "text-ink hover:bg-app-selected"
}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(model.id);
}}
onMouseEnter={() => setHighlightIndex(flatIdx)}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep the option rows keyboard-activatable.

Line 329 only calls handleSelect from onMouseDown, so a user who tabs to a model and presses Enter/Space gets a dead button. Keep onMouseDown for blur suppression, but move the actual selection to onClick.

Suggested fix
                       <button
                         key={model.id}
                         type="button"
                         data-model-item
                         className={`w-full text-left px-3 py-1.5 text-sm transition-colors flex items-center justify-between gap-2 ${
                           isHighlighted
                             ? "bg-accent/20 text-ink"
                             : isSelected
                               ? "bg-app-selected/50 text-ink"
                               : "text-ink hover:bg-app-selected"
                         }`}
-                        onMouseDown={(e) => {
-                          e.preventDefault();
-                          handleSelect(model.id);
-                        }}
+                        onMouseDown={(e) => e.preventDefault()}
+                        onClick={() => handleSelect(model.id)}
                         onMouseEnter={() => setHighlightIndex(flatIdx)}
                       >
📝 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
<button
key={model.id}
type="button"
data-model-item
className={`w-full text-left px-3 py-1.5 text-sm transition-colors flex items-center justify-between gap-2 ${
isHighlighted
? "bg-accent/20 text-ink"
: isSelected
? "bg-app-selected/50 text-ink"
: "text-ink hover:bg-app-selected"
}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(model.id);
}}
onMouseEnter={() => setHighlightIndex(flatIdx)}
<button
key={model.id}
type="button"
data-model-item
className={`w-full text-left px-3 py-1.5 text-sm transition-colors flex items-center justify-between gap-2 ${
isHighlighted
? "bg-accent/20 text-ink"
: isSelected
? "bg-app-selected/50 text-ink"
: "text-ink hover:bg-app-selected"
}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => handleSelect(model.id)}
onMouseEnter={() => setHighlightIndex(flatIdx)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/ModelSelect.tsx` around lines 318 - 333, The option
buttons currently call handleSelect(model.id) only from the onMouseDown handler
which prevents keyboard activation; keep the existing onMouseDown behavior to
suppress blur but remove the call to handleSelect from onMouseDown and instead
invoke handleSelect(model.id) from an onClick handler on the same button
element; update the button that uses setHighlightIndex and onMouseEnter to add
onClick={() => handleSelect(model.id)} and ensure onMouseDown only calls
e.preventDefault() to preserve blur suppression.

Comment on lines +35 to +41
if ($TargetTriple -ne $HostTriple) {
cargo build @CargoFlags --target $TargetTriple --manifest-path "$RepoRoot\Cargo.toml"
$SrcBin = Join-Path $RepoRoot "target\$TargetTriple\$BuildMode\spacebot.exe"
} else {
cargo build @CargoFlags --manifest-path "$RepoRoot\Cargo.toml"
$SrcBin = Join-Path $RepoRoot "target\$BuildMode\spacebot.exe"
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing exit code check after cargo build — script continues on build failure.

$ErrorActionPreference = "Stop" only affects PowerShell cmdlets, not external executables. If cargo build fails, the script continues and will attempt to copy a non-existent binary. Add an explicit $LASTEXITCODE check after each cargo invocation.

🐛 Proposed fix to check cargo exit code
 if ($TargetTriple -ne $HostTriple) {
     cargo build `@CargoFlags` --target $TargetTriple --manifest-path "$RepoRoot\Cargo.toml"
+    if ($LASTEXITCODE -ne 0) {
+        Write-Error "cargo build failed with exit code $LASTEXITCODE"
+        exit $LASTEXITCODE
+    }
     $SrcBin = Join-Path $RepoRoot "target\$TargetTriple\$BuildMode\spacebot.exe"
 } else {
     cargo build `@CargoFlags` --manifest-path "$RepoRoot\Cargo.toml"
+    if ($LASTEXITCODE -ne 0) {
+        Write-Error "cargo build failed with exit code $LASTEXITCODE"
+        exit $LASTEXITCODE
+    }
     $SrcBin = Join-Path $RepoRoot "target\$BuildMode\spacebot.exe"
 }
📝 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 ($TargetTriple -ne $HostTriple) {
cargo build @CargoFlags --target $TargetTriple --manifest-path "$RepoRoot\Cargo.toml"
$SrcBin = Join-Path $RepoRoot "target\$TargetTriple\$BuildMode\spacebot.exe"
} else {
cargo build @CargoFlags --manifest-path "$RepoRoot\Cargo.toml"
$SrcBin = Join-Path $RepoRoot "target\$BuildMode\spacebot.exe"
}
if ($TargetTriple -ne $HostTriple) {
cargo build `@CargoFlags` --target $TargetTriple --manifest-path "$RepoRoot\Cargo.toml"
if ($LASTEXITCODE -ne 0) {
Write-Error "cargo build failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
$SrcBin = Join-Path $RepoRoot "target\$TargetTriple\$BuildMode\spacebot.exe"
} else {
cargo build `@CargoFlags` --manifest-path "$RepoRoot\Cargo.toml"
if ($LASTEXITCODE -ne 0) {
Write-Error "cargo build failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
$SrcBin = Join-Path $RepoRoot "target\$BuildMode\spacebot.exe"
}
🧰 Tools
🪛 PSScriptAnalyzer (1.24.0)

[warning] Missing BOM encoding for non-ASCII encoded file 'bundle-sidecar.ps1'

(PSUseBOMForUnicodeEncodedFile)

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

In `@scripts/bundle-sidecar.ps1` around lines 35 - 41, After each external cargo
build invocation (the two branches that run "cargo build" when TargetTriple vs
HostTriple are compared), check $LASTEXITCODE immediately and abort if non-zero;
in practice, after the cargo build calls referenced by CargoFlags, TargetTriple,
HostTriple, BuildMode and RepoRoot, inspect $LASTEXITCODE and call exit
$LASTEXITCODE or throw an error before continuing to set $SrcBin or perform any
copy so the script does not proceed when the build failed.

Comment on lines +175 to +192
let mut remove_count = total / 2;

// Advance past User messages containing ToolResult blocks so we never
// orphan tool_results whose matching tool_use was in a removed assistant
// message (the Anthropic API rejects orphaned tool_result blocks).
while remove_count < total.saturating_sub(2) {
let has_tool_result = matches!(
history.get(remove_count),
Some(Message::User { content })
if content.iter().any(|item|
matches!(item, UserContent::ToolResult(_)))
);
if has_tool_result {
remove_count += 1;
} else {
break;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The boundary scan still misses the total - 2 orphan case.

At Lines 180-192 and 235-247 the scan only runs while remove_count < total.saturating_sub(2). For a 4-message history like [User, Assistant(ToolCall), User(ToolResult), Assistant], aggressive/emergency compaction sets remove_count = 2, never enters the loop, and the kept history still starts with the orphaned ToolResult. That means the Anthropic rejection this change is trying to avoid can still happen. Please handle the == total.saturating_sub(2) case here too—either by allowing one more removal or by splitting the boundary User message instead of keeping it wholesale.

Also applies to: 226-247

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

In `@src/agent/compactor.rs` around lines 175 - 192, The boundary scan can miss
the case when remove_count == total.saturating_sub(2), leaving an orphaned User
ToolResult; update the loop in compactor.rs that advances remove_count (the one
using history.get(remove_count) and matching Message::User /
UserContent::ToolResult) to include the equality case (e.g., use <= or remove
the strict < bound) and, when the scan reaches exactly total.saturating_sub(2),
handle it by splitting the boundary User message instead of keeping it whole
(i.e., trim or split the User content so ToolResult is not orphaned) — apply the
same change to the analogous scan block referenced around lines 226-247.

Comment on lines +749 to +770
let mut remove_count = ((total as f32 * fraction) as usize)
.max(1)
.min(total.saturating_sub(2));

// Advance the boundary past User messages that contain ToolResult blocks.
// If we stop right after an Assistant message with ToolCalls, the next
// User message holds the corresponding ToolResults. Leaving those orphaned
// causes the Anthropic API to reject the request with:
// "unexpected tool_use_id found in tool_result blocks"
while remove_count < total.saturating_sub(2) {
let has_tool_result = matches!(
history.get(remove_count),
Some(rig::message::Message::User { content })
if content.iter().any(|item|
matches!(item, rig::message::UserContent::ToolResult(_)))
);
if has_tool_result {
remove_count += 1;
} else {
break;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Overflow recovery still has the same off-by-one hole.

At Lines 758-770 remove_count is clamped to total.saturating_sub(2) before the scan, and the loop only runs while it is strictly smaller. With fraction = 0.75 and a 5-message history, remove_count becomes 3; if history[3] is the User(ToolResult) paired with a removed assistant tool call, the retry keeps the orphan and Anthropic rejects the follow-up again. This path needs the same == total - 2 handling as the channel compactor path.

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

In `@src/agent/worker.rs` around lines 749 - 770, The overflow-recovery loop can
still leave an orphaned User ToolResult when remove_count equals
total.saturating_sub(2); change the loop bounds so the scan includes that index.
Specifically, adjust the loop condition around remove_count/total (and the
initial clamping if needed) so the while allows checking when remove_count ==
total.saturating_sub(2) (e.g., use <= instead of < or clamp differently) and
keep the existing logic that increments remove_count when
history.get(remove_count) matches rig::message::Message::User with
UserContent::ToolResult; this mirrors the channel compactor fix so the paired
ToolResult is advanced past the removal boundary.

Comment on lines +341 to +345
if has_key("ollama_base_url", "OLLAMA_BASE_URL")
|| has_key("ollama_key", "OLLAMA_API_KEY")
{
providers.push("ollama");
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

New providers are configured but never resolvable from the catalog.

Line 341-Line 369 adds ollama, nvidia, and github-copilot to configured providers, but direct_provider_mapping (Line 104-Line 123) never emits these provider IDs. As a result, catalog models for these providers fall back to "openrouter" and won’t match configured.contains(...) or provider=<name> filters.

Proposed fix
 fn direct_provider_mapping(models_dev_id: &str) -> Option<&'static str> {
     match models_dev_id {
         "anthropic" => Some("anthropic"),
         "openai" => Some("openai"),
+        "ollama" => Some("ollama"),
+        "nvidia" => Some("nvidia"),
+        "github-copilot" => Some("github-copilot"),
         "kilo" => Some("kilo"),
         "deepseek" => Some("deepseek"),
         "xai" => Some("xai"),
         "mistral" => Some("mistral"),
         "gemini" | "google" => Some("gemini"),

Also applies to: 352-354, 367-369

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

In `@src/api/models.rs` around lines 341 - 345, The new branches that push
"ollama", "nvidia", and "github-copilot" into providers are never translated by
direct_provider_mapping, so catalog lookups fall back to "openrouter" and
filters like configured.contains(...) or provider=<name> fail; update
direct_provider_mapping to emit the corresponding provider IDs for Ollama,
NVIDIA, and GitHub Copilot (or add a mapping helper used by
direct_provider_mapping) so that provider strings produced by
providers.push("ollama"/"nvidia"/"github-copilot") match catalog entries and the
configured.contains(...) / provider=<name> filters. Ensure the mapping is
symmetric with has_key(...) usage and update any tests or usages of
direct_provider_mapping to include the new IDs.

Comment on lines +2057 to +2067
if let Some(mut browser) = browser {
if let Err(error) = browser.close().await {
// The browser connection may already be dead (e.g. "oneshot canceled"
// from a dropped WebSocket). That's fine — the browser is effectively
// closed. Log it but don't fail the tool call.
tracing::warn!(
policy = "close_browser",
%error,
"browser.close() failed, treating as already closed"
);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In chromiumoxide, what documented/observed errors can Browser::close().await return, and which ones indicate an already-closed/disconnected session vs a real close failure?

💡 Result:

Browser::close.await returns Result<, CdpError>. Documented/observed errors from official docs and issues include: - CdpError::Ws(tungstenite::Error): WebSocket errors, e.g., connection reset or closed (Io(Os { code: 10054, kind: ConnectionReset, message: "An existing connection was forcibly closed by the remote host." })). These typically indicate an already-closed or disconnected session, as the browser process may have terminated or WS disconnected before/during close. - CdpError::Serde(serde_json::Error): JSON deserialization failures on CDP responses. Observed when Chrome sends unexpected messages; library now skips them but could error on close command response. - CdpError::Chrome(chromiumoxide_types::Error): Chrome DevTools Protocol errors from the Browser.close command. - CdpError::ChromeMessage(String): Non-standard CDP error messages. - Other potential: CdpError::Io, CdpError::NoResponse, CdpError::Timeout. Already-closed/disconnected vs real failure: - WS/Io errors (e.g., ConnectionReset) indicate the session was already closed/disconnected (browser exited independently). Safe to treat as success. - Serde/Chrome errors indicate real failures in command execution/response (e.g., browser rejected close, invalid state). - Docs recommend calling close then wait for spawned browsers to avoid zombies; close failure doesn't prevent Drop from cleaning up. Examples from issues: WS Connection error on handler (not directly close, but related to session end); Serde on invalid messages during operation.

Citations:


🏁 Script executed:

# First, let's look at the full context around the flagged lines
sed -n '2050,2085p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 1606


🏁 Script executed:

# Check the function signature to understand the return type
sed -n '2000,2070p' src/tools/browser.rs | grep -A 50 "fn\|return\|Result"

Repository: spacedriveapp/spacebot

Length of output: 1968


🏁 Script executed:

# Search for how CdpError is used elsewhere in the file
rg "CdpError" src/tools/browser.rs -A 3 -B 1

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Check what chromiumoxide error types are available
rg "use.*chromiumoxide" src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 543


🏁 Script executed:

# Get the function signature to confirm return type
grep -B 20 "ClosePolicy::CloseBrowser =>" src/tools/browser.rs | grep -E "fn |async|Result"

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Check what error type browser.close() returns - look for type annotations
sed -n '1900,2100p' src/tools/browser.rs | grep -A 5 "fn.*close\|Result\|BrowserError"

Repository: spacedriveapp/spacebot

Length of output: 1729


🏁 Script executed:

# Look at the function definition for the close method to understand its return type
sed -n '1950,2000p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 1641


🏁 Script executed:

# Search for BrowserError definition and how it's constructed
rg "struct BrowserError|impl BrowserError" src/tools/browser.rs -A 5

Repository: spacedriveapp/spacebot

Length of output: 256


🏁 Script executed:

# Check if CdpError is imported or used
rg "CdpError\|from_cdp\|.into()" src/tools/browser.rs | head -20

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Find the actual close() method signature in chromiumoxide
# First check what Browser type is and look for close implementation
rg "impl.*Browser" src/tools/browser.rs -A 10 | head -30

Repository: spacedriveapp/spacebot

Length of output: 1115


🏁 Script executed:

# Look for the browser variable type annotation
sed -n '2045,2070p' src/tools/browser.rs | grep -B 20 "browser.close"

Repository: spacedriveapp/spacebot

Length of output: 1135


🏁 Script executed:

# Check the state struct to see what type browser is
rg "struct.*State|browser:" src/tools/browser.rs -A 5 | head -40

Repository: spacedriveapp/spacebot

Length of output: 1521


🏁 Script executed:

# Look at chromiumoxide documentation - check if CdpError variants are public/matchable
python3 << 'EOF'
# Let's check what information we can gather about CdpError from the codebase
import subprocess
result = subprocess.run(['rg', 'CdpError', 'src/tools/browser.rs'], capture_output=True, text=True)
print("CdpError references in browser.rs:")
print(result.stdout)
EOF

Repository: spacedriveapp/spacebot

Length of output: 101


🏁 Script executed:

# Check if there are any error conversion patterns or match statements
rg "match.*error|Err\(|if let Err" src/tools/browser.rs | grep -v "//" | head -15

Repository: spacedriveapp/spacebot

Length of output: 895


🏁 Script executed:

# Check what error type browser.close() returns - look at Browser impl
grep -n "close()" src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 312


🏁 Script executed:

# Let's look at how other chromiumoxide errors are handled - check page.close()
sed -n '2003,2015p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 613


🏁 Script executed:

# Check if chromiumoxide_types or chromiumoxide::error is imported
rg "use chromiumoxide" src/tools/browser.rs | grep -i error

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Try to determine if CdpError can be imported and pattern matched
# Check chromiumoxide imports and see what's available
head -50 src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 2190


🏁 Script executed:

# Search for any error handling with pattern matching to see the pattern used in this file
rg "match.*error|CdpError::" src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Check if chromiumoxide::error or CdpError can be imported
fd "Cargo.toml" | head -5 | xargs grep -l "chromiumoxide"

Repository: spacedriveapp/spacebot

Length of output: 76


🏁 Script executed:

# Look at Cargo.toml to see chromiumoxide version and features
cat Cargo.toml | grep -A 5 "chromiumoxide"

Repository: spacedriveapp/spacebot

Length of output: 309


🏁 Script executed:

# Check if CdpError type is used anywhere or if error handling uses Display/to_string
rg "error\.to_string\|Display\|fmt" src/tools/browser.rs | head -10

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Let's see how the error is being displayed in tracing::warn
# Check what traits are available on the error
sed -n '2058,2068p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 669


🏁 Script executed:

# See if there are other places where chromiumoxide errors are handled with pattern matching
fd "\.rs" src/ | xargs grep -l "chromiumoxide" | xargs grep -l "match.*error\|CdpError" 2>/dev/null | head -5

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Check chromiumoxide 0.8 docs - see if CdpError is public and has matchable variants
# Let's search for any existing code that does pattern match on CdpError
rg "CdpError::" . --type rs 2>/dev/null | head -10

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Try to understand if the error type can be inspected - check what methods are available
# Look at how similar errors are handled elsewhere in the codebase
rg "\.await\s*\)" src/tools/browser.rs -A 2 | grep -A 2 "Err" | head -20

Repository: spacedriveapp/spacebot

Length of output: 48


🏁 Script executed:

# Let's check the actual drop implementation and understand the cleanup behavior
sed -n '1992,2020p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 1287


🏁 Script executed:

# Look at what happens in Drop for BrowserState
rg -A 15 "impl Drop for BrowserState" src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 769


🏁 Script executed:

# Check if there's documentation or comments about error suppression patterns elsewhere
rg "safe to ignore|already|cleanup|drop" src/tools/browser.rs | head -10

Repository: spacedriveapp/spacebot

Length of output: 722


🏁 Script executed:

# Let's check if chromiumoxide 0.8 has a public CdpError enum that can be pattern matched
# Try to find any imports or usage of error types
rg "use.*error|CdpError|Error" src/tools/browser.rs | grep "use\|::" | head -20

Repository: spacedriveapp/spacebot

Length of output: 1438


🏁 Script executed:

# Check if there's any conditional logic on error types in chromiumoxide or similar crates
# Look at the entire error handling flow for CloseTabs to see if it handles errors differently
sed -n '2010,2045p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 1570


🏁 Script executed:

# Check what happens after close_errors are collected in CloseTabs
sed -n '2025,2055p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 1419


🏁 Script executed:

# Check the full function return path for CloseBrowser to be sure
sed -n '2040,2090p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 2239


🏁 Script executed:

# Verify the inconsistency: check what CloseTabs does with close errors
sed -n '2019,2037p' src/tools/browser.rs

Repository: spacedriveapp/spacebot

Length of output: 776


🏁 Script executed:

# Check if CdpError can be imported from chromiumoxide and what variants it has
# This will help validate if pattern matching is possible vs string matching
python3 << 'EOF'
import subprocess
import json

# Try to check chromiumoxide documentation or source 
result = subprocess.run(
    ['rg', 'chromiumoxide.*error|CdpError', 'Cargo.lock'],
    capture_output=True,
    text=True
)
print("Cargo.lock chromiumoxide references:")
print(result.stdout[:500] if result.stdout else "Not found")

# Check if there's a Cargo.lock to understand exact version
result2 = subprocess.run(['cat', 'Cargo.lock'], capture_output=True, text=True)
lines = result2.stdout.split('\n')
for i, line in enumerate(lines):
    if 'chromiumoxide' in line.lower() and i < len(lines) - 5:
        print('\n'.join(lines[i:min(i+10, len(lines))]))
        break
EOF

Repository: spacedriveapp/spacebot

Length of output: 412


Inconsistency: CloseBrowser silently suppresses close failures while CloseTabs propagates them.

Lines 2058-2068 suppress all browser.close() errors and return success; this contradicts CloseTabs behavior (lines 2019-2037), which collects close errors and returns Err if any occurred. Silent suppression can mask real shutdown failures and return false "Browser closed" success.

Known benign errors (WebSocket disconnection, session timeouts) exist, but suppressing all errors without distinction is unreliable. Align with CloseTabs error handling: distinguish between benign disconnection errors and real close failures.

🔧 Suggested fix
                 if let Some(mut browser) = browser {
                     if let Err(error) = browser.close().await {
-                        // The browser connection may already be dead (e.g. "oneshot canceled"
-                        // from a dropped WebSocket). That's fine — the browser is effectively
-                        // closed. Log it but don't fail the tool call.
-                        tracing::warn!(
-                            policy = "close_browser",
-                            %error,
-                            "browser.close() failed, treating as already closed"
-                        );
+                        let error_text = error.to_string();
+                        let benign_disconnect = error_text.contains("oneshot canceled")
+                            || error_text.contains("channel closed")
+                            || error_text.contains("connection reset");
+                        if benign_disconnect {
+                            tracing::warn!(
+                                policy = "close_browser",
+                                %error,
+                                "browser.close() failed due to disconnected session; treating as already closed"
+                            );
+                        } else {
+                            return Err(BrowserError::new(format!(
+                                "failed to close browser: {error}"
+                            )));
+                        }
                     }
                 }
📝 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 let Some(mut browser) = browser {
if let Err(error) = browser.close().await {
// The browser connection may already be dead (e.g. "oneshot canceled"
// from a dropped WebSocket). That's fine — the browser is effectively
// closed. Log it but don't fail the tool call.
tracing::warn!(
policy = "close_browser",
%error,
"browser.close() failed, treating as already closed"
);
}
if let Some(mut browser) = browser {
if let Err(error) = browser.close().await {
let error_text = error.to_string();
let benign_disconnect = error_text.contains("oneshot canceled")
|| error_text.contains("channel closed")
|| error_text.contains("connection reset");
if benign_disconnect {
tracing::warn!(
policy = "close_browser",
%error,
"browser.close() failed due to disconnected session; treating as already closed"
);
} else {
return Err(BrowserError::new(format!(
"failed to close browser: {error}"
)));
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/browser.rs` around lines 2057 - 2067, CloseBrowser currently
swallows all errors from browser.close() while CloseTabs collects and returns
errors; change CloseBrowser to mirror CloseTabs by distinguishing benign
disconnection/session-timeout errors (log as warn and treat as closed) from real
close failures: inspect the error returned by browser.close(), map known benign
patterns (e.g., "oneshot canceled", websocket disconnect/session timeout) to
warnings and continue, but for other errors collect/return Err (propagate an
error result) instead of silently succeeding; update the CloseBrowser control
flow around browser.close().await and the tracing::warn call to follow the same
error-collection/return behavior used in CloseTabs.

Copy link
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: 2

🧹 Nitpick comments (1)
interface/src/components/WebChatPanel.tsx (1)

185-227: Keep the live footer separate from the historical timeline.

timeline.map(...) is the expensive work here, but isTyping and error are bundled into the same memoized component, so every typing-start/typing-stop or error change still remaps the full history. Pulling the footer out—or memoizing rows by item.id—would preserve more of the perf win on long sessions.

Worth confirming in React Profiler that toggling isTyping no longer rerenders the historical bubbles after the split.

Possible refactor
-const MessageList = memo(function MessageList({
-	timeline,
-	isTyping,
-	error,
-}: {
-	timeline: TimelineItem[];
-	isTyping: boolean;
-	error: string | null;
-}) {
+const MessageHistory = memo(function MessageHistory({
+	timeline,
+}: {
+	timeline: TimelineItem[];
+}) {
 	return (
 		<>
 			{timeline.map((item) => {
@@
-			{/* Typing indicator */}
-			{isTyping && <ThinkingIndicator />}
-
-			{error && (
-				<div className="rounded-lg border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-400">
-					{error}
-				</div>
-			)}
 		</>
 	);
 });
@@
-					<MessageList
-						timeline={timeline}
-						isTyping={isTyping}
-						error={error}
-					/>
+					<MessageHistory timeline={timeline} />
+					{isTyping && <ThinkingIndicator />}
+					{error && (
+						<div className="rounded-lg border border-red-500/20 bg-red-500/5 px-4 py-3 text-sm text-red-400">
+							{error}
+						</div>
+					)}

Also applies to: 277-281

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

In `@interface/src/components/WebChatPanel.tsx` around lines 185 - 227, The
MessageList currently re-maps the entire timeline whenever isTyping or error
changes; fix this by extracting the live footer (the {isTyping &&
<ThinkingIndicator />} and the {error && <div ...>{error}</div>} block) into a
separate component (e.g., LiveFooter) rendered outside the timeline.map, or
alternatively create a memoized MessageRow component (e.g., function
MessageRow({item}) wrapped with memo and keyed by item.id) and replace the
inline JSX inside timeline.map with <MessageRow item={item} /> so timeline items
don't re-render when isTyping/error change; update MessageList to render
timeline.map of memoized rows plus the LiveFooter and verify in React Profiler
that toggling isTyping no longer re-renders historical message bubbles.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@interface/src/components/CortexChatPanel.tsx`:
- Around line 191-199: doSubmit currently clears the textarea and draft
immediately, which can drop the first message when the send is rejected while
threadId is null; change doSubmit (and the other similar handlers at the noted
locations) to either (A) gate submission by checking threadId/newThread
readiness before calling onSubmit, or (B) make onSubmit return a boolean/Promise
that indicates success and only clear textareaRef.current.value, call
setHasText(false), and adjustHeight() after a successful send; update callers of
onSubmit as needed and reference doSubmit, textareaRef, onSubmit, threadId, and
the useCortexChat hook to locate and coordinate the change.
- Around line 373-376: The CortexChatPanel rendering collapses newlines because
the message bubble uses a <p> that renders message.content without preserving
whitespace; update the bubble that renders message.content in the
CortexChatPanel component to use the same whitespace handling as WebChatPanel
(e.g., render message.content inside an element/class that applies CSS
whitespace: pre-wrap / Tailwind "whitespace-pre-wrap") so user multi-line
prompts (Shift+Enter) appear with line breaks preserved. Ensure you change the
element rendering message.content (the <p className="text-sm text-ink">...) to
the whitespace-preserving variant and keep existing styling intact.

---

Nitpick comments:
In `@interface/src/components/WebChatPanel.tsx`:
- Around line 185-227: The MessageList currently re-maps the entire timeline
whenever isTyping or error changes; fix this by extracting the live footer (the
{isTyping && <ThinkingIndicator />} and the {error && <div ...>{error}</div>}
block) into a separate component (e.g., LiveFooter) rendered outside the
timeline.map, or alternatively create a memoized MessageRow component (e.g.,
function MessageRow({item}) wrapped with memo and keyed by item.id) and replace
the inline JSX inside timeline.map with <MessageRow item={item} /> so timeline
items don't re-render when isTyping/error change; update MessageList to render
timeline.map of memoized rows plus the LiveFooter and verify in React Profiler
that toggling isTyping no longer re-renders historical message bubbles.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ccb4a198-19ca-4045-83ec-813ead6ce16b

📥 Commits

Reviewing files that changed from the base of the PR and between 2307d34 and 2e07d75.

📒 Files selected for processing (2)
  • interface/src/components/CortexChatPanel.tsx
  • interface/src/components/WebChatPanel.tsx

Comment on lines +191 to +199
const doSubmit = () => {
const textarea = textareaRef.current;
if (!textarea) return;
const trimmed = textarea.value.trim();
if (!trimmed) return;
textarea.value = "";
setHasText(false);
adjustHeight();
onSubmit(trimmed);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Only clear the Cortex draft after the send is accepted.

interface/src/hooks/useCortexChat.ts:81-103 still no-ops while threadId is null, but doSubmit() clears the textarea before this callback can reject. That makes the first message easy to lose during fresh-thread startup or right after newThread(). Please gate the input on threadId, or have onSubmit report whether the send was accepted and only clear on success.

Possible fix
 const CortexChatInput = memo(function CortexChatInput({
 	onSubmit,
 	isStreaming,
+	canSubmit,
 }: {
-	onSubmit: (text: string) => void;
+	onSubmit: (text: string) => boolean;
 	isStreaming: boolean;
+	canSubmit: boolean;
 }) {
@@
 	const doSubmit = () => {
 		const textarea = textareaRef.current;
 		if (!textarea) return;
 		const trimmed = textarea.value.trim();
 		if (!trimmed) return;
-		textarea.value = "";
-		setHasText(false);
-		adjustHeight();
-		onSubmit(trimmed);
+		if (!onSubmit(trimmed)) return;
+		textarea.value = "";
+		setHasText(false);
+		adjustHeight();
 	};
@@
-					disabled={isStreaming}
+					disabled={isStreaming || !canSubmit}
@@
-					disabled={isStreaming || !hasText}
+					disabled={isStreaming || !hasText || !canSubmit}
 const handleSubmit = useCallback(
 	(text: string) => {
-		if (isStreaming) return;
+		if (isStreaming || !threadId) return false;
 		sendMessage(text);
+		return true;
 	},
-	[isStreaming, sendMessage],
+	[isStreaming, threadId, sendMessage],
 );
@@
 				<CortexChatInput
 					onSubmit={handleSubmit}
 					isStreaming={isStreaming}
+					canSubmit={!!threadId}
 				/>

Also applies to: 439-445, 557-560

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

In `@interface/src/components/CortexChatPanel.tsx` around lines 191 - 199,
doSubmit currently clears the textarea and draft immediately, which can drop the
first message when the send is rejected while threadId is null; change doSubmit
(and the other similar handlers at the noted locations) to either (A) gate
submission by checking threadId/newThread readiness before calling onSubmit, or
(B) make onSubmit return a boolean/Promise that indicates success and only clear
textareaRef.current.value, call setHasText(false), and adjustHeight() after a
successful send; update callers of onSubmit as needed and reference doSubmit,
textareaRef, onSubmit, threadId, and the useCortexChat hook to locate and
coordinate the change.

Comment on lines +373 to +376
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2">
<p className="text-sm text-ink">{message.content}</p>
</div>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Preserve multiline user prompts in the Cortex bubble.

The new textarea supports Shift+Enter, but this <p> still collapses \n into spaces, so multi-line prompts render as a single line after send. Matching the whitespace handling already used in interface/src/components/WebChatPanel.tsx:202-205 would keep the displayed message faithful to what the user entered.

Possible fix
-								<p className="text-sm text-ink">{message.content}</p>
+								<p className="break-words whitespace-pre-wrap text-sm text-ink">
+									{message.content}
+								</p>
📝 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
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2">
<p className="text-sm text-ink">{message.content}</p>
</div>
<div className="flex justify-end">
<div className="max-w-[85%] rounded-2xl rounded-br-md bg-app-hover/30 px-3 py-2">
<p className="break-words whitespace-pre-wrap text-sm text-ink">
{message.content}
</p>
</div>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/components/CortexChatPanel.tsx` around lines 373 - 376, The
CortexChatPanel rendering collapses newlines because the message bubble uses a
<p> that renders message.content without preserving whitespace; update the
bubble that renders message.content in the CortexChatPanel component to use the
same whitespace handling as WebChatPanel (e.g., render message.content inside an
element/class that applies CSS whitespace: pre-wrap / Tailwind
"whitespace-pre-wrap") so user multi-line prompts (Shift+Enter) appear with line
breaks preserved. Ensure you change the element rendering message.content (the
<p className="text-sm text-ink">...) to the whitespace-preserving variant and
keep existing styling intact.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant