Skip to content

fix(browser): non-blocking tailscale detect + wire connecting state + sidebar a11y (#124)#1229

Merged
jaylfc merged 2 commits into
devfrom
fix/browser-124-nits
Jun 20, 2026
Merged

fix(browser): non-blocking tailscale detect + wire connecting state + sidebar a11y (#124)#1229
jaylfc merged 2 commits into
devfrom
fix/browser-124-nits

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • Async tailscale detect (tinyagentos/worker/browser_container.py): build_neko_run_args() calls build_nat1to1() which runs tailscale ip as a blocking subprocess. Wrapped the call in await asyncio.to_thread() at the async call site in BrowserContainerRunner.start(). The sync function itself is unchanged so all existing tests keep working.

  • Connecting empty state (desktop/src/apps/BrowserApp/TabRenderer.tsx): Introduced LiveSessionSlot, a thin wrapper around LiveBrowserView that shows BrowserEmptyState variant="connecting" as an overlay until the neko iframe fires its first load event (detected via MutationObserver since the iframe mounts inside LiveBrowserView after the wrapper). LiveBrowserView is untouched.

  • Sidebar a11y (desktop/src/apps/BrowserApp/BrowserSidebar.tsx): Replaced <div role="option" aria-selected onClick> (orphaned, no listbox parent, no keyboard support) with a native <button> on SidebarTabRow. Keyboard and screen-reader access now work for free. The close affordance uses role="button" on a <span> to avoid nested <button> elements. Visual styling and collapsed/icon-only state are unchanged.

Test results

uv run pytest tests/ -k browser -q
786 passed, 5902 deselected in 147s

cd desktop && npx tsc --noEmit
(no output — clean)

cd desktop && npx vitest run
Test Files  224 passed (224)
Tests       1831 passed (1831)

Closes taOS task #124.

Summary by CodeRabbit

  • New Features

    • Live browser session tabs now display a connecting indicator while establishing remote connections, keeping users informed of connection status.
  • Improvements

    • Enhanced accessibility of browser tab controls with improved semantic markup for tab navigation and close button interactions, providing better screen reader support.
    • Optimized browser container initialization for faster startup responsiveness.

@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jaylfc, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 11 minutes and 20 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 40dd676a-7d3a-42cc-be05-e5af3867b611

📥 Commits

Reviewing files that changed from the base of the PR and between d09625c and f340043.

📒 Files selected for processing (4)
  • desktop/src/apps/BrowserApp/BrowserSidebar.tsx
  • desktop/src/apps/BrowserApp/TabRenderer.tsx
  • tinyagentos/routes/browser_sessions.py
  • tinyagentos/worker/browser_container.py
📝 Walkthrough

Walkthrough

Three independent fixes: BrowserSidebar tab row semantics switch from div/role="option" to button/aria-pressed and the close control from button to span role="button"; TabRenderer introduces LiveSessionSlot that shows a "connecting" overlay until the Neko iframe loads; browser_container.py wraps build_neko_run_args in asyncio.to_thread to avoid blocking during Tailscale detection.

Changes

Frontend Browser UI

Layer / File(s) Summary
Sidebar tab row and close control ARIA semantics
desktop/src/apps/BrowserApp/BrowserSidebar.tsx
Tab row root changes from div with role="option"/aria-selected to button with aria-pressed; close affordance changes from button (with tabIndex={-1}) to span with role="button" and aria-label; JSX closing structure updated to match new nesting.
LiveSessionSlot connecting overlay
desktop/src/apps/BrowserApp/TabRenderer.tsx
Adds useRef import; active liveSession render site replaced with new LiveSessionSlot component; LiveSessionSlot renders LiveBrowserView and overlays BrowserEmptyState ("connecting") until the Neko iframe fires its first load event, attaching the handler directly or via MutationObserver if the iframe is not yet in the DOM, with cleanup on unmount.

Backend Async Fix

Layer / File(s) Summary
Offload build_neko_run_args to worker thread
tinyagentos/worker/browser_container.py
BrowserContainerRunner.start() wraps build_neko_run_args(...) in asyncio.to_thread(...) so the blocking _detect_tailscale_ip subprocess call no longer stalls the event loop.

Sequence Diagram(s)

sequenceDiagram
  participant TabRenderer
  participant LiveSessionSlot
  participant MutationObserver
  participant NekoIframe

  TabRenderer->>LiveSessionSlot: render(tabId, nekoUrl, streamToken)
  LiveSessionSlot->>LiveSessionSlot: isConnecting=true → show BrowserEmptyState overlay
  LiveSessionSlot->>NekoIframe: check if iframe already in DOM
  alt iframe already mounted
    LiveSessionSlot->>NekoIframe: addEventListener("load", onLoad)
  else iframe not yet mounted
    LiveSessionSlot->>MutationObserver: observe container
    MutationObserver->>NekoIframe: iframe detected in DOM
    MutationObserver->>NekoIframe: addEventListener("load", onLoad)
    MutationObserver->>MutationObserver: disconnect()
  end
  NekoIframe-->>LiveSessionSlot: "load" event fires
  LiveSessionSlot->>LiveSessionSlot: isConnecting=false → hide overlay
  LiveSessionSlot->>NekoIframe: removeEventListener("load", onLoad)
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • jaylfc/taOS#944: Introduces liveSession streamed mode on BrowserModeToggle, which is the feature that the new LiveSessionSlot connecting overlay wraps.
  • jaylfc/taOS#1227: Implements BrowserSidebar with the original SidebarTabRow close button markup that this PR's accessibility changes update.
  • jaylfc/taOS#1228: Modifies build_neko_run_args and the Tailscale/NAT1TO1 IP detection path that this PR offloads to a worker thread.

Poem

🐇 Hop, hop, the button clicks with grace,
A span now holds the close-tab space.
A "connecting" veil appears mid-stream,
While Tailscale probes in threads unseen.
No loop is blocked, no iframe forgot —
The rabbit tidied every spot! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.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 title clearly and concisely summarizes all three main changes: non-blocking tailscale detection, connecting state UI wiring, and sidebar accessibility improvements.
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/browser-124-nits

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 on lines +374 to +388
function LiveSessionSlot({ tabId, nekoUrl, streamToken }: LiveSessionSlotProps) {
const [connecting, setConnecting] = useState(true);
const wrapperRef = useRef<HTMLDivElement>(null);

useEffect(() => {
// Listen for the neko iframe's load event; once it fires, hide the
// connecting overlay. The iframe is rendered by LiveBrowserView inside
// wrapperRef, so we can find it once it mounts.
const wrapper = wrapperRef.current;
if (!wrapper) return;

const onLoad = () => setConnecting(false);

// The iframe may already be present (SSR / fast mount); attach directly if so.
const iframe = wrapper.querySelector("iframe");

@gitar-bot gitar-bot Bot Jun 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Edge Case: Connecting overlay can get stuck if iframe load is missed

LiveSessionSlot keeps the connecting overlay (BrowserEmptyState variant="connecting") visible until the neko iframe fires a load event. The overlay is absolute inset-0 with a solid bg-shell-bg-deep background and no pointer-events-none, so while connecting is true it fully obscures and blocks all interaction with the live session.

There is no fallback if the load event is never observed:

  • In the MutationObserver branch (lines 395-401), the observer callback runs asynchronously after the iframe is inserted. If the iframe's load event fires before the callback attaches the listener (e.g. cached/instant load), the event is missed and setConnecting(false) is never called.
  • If the neko iframe never emits a load event for any reason, the overlay stays up permanently, leaving the user staring at the "Starting…" state over a session that is actually live.

Consider adding a safety timeout that clears connecting after a few seconds regardless, and/or checking iframe.contentDocument?.readyState/iframe.complete-style already-loaded state when attaching the listener so an already-fired load is not missed.

Add a timeout fallback so the connecting overlay can never block the live view indefinitely.:

useEffect(() => {
  const wrapper = wrapperRef.current;
  if (!wrapper) return;
  const onLoad = () => setConnecting(false);
  // Safety net: clear the overlay even if the load event is missed.
  const timer = window.setTimeout(() => setConnecting(false), 15000);
  const attach = (el: HTMLIFrameElement) => el.addEventListener("load", onLoad);
  const existing = wrapper.querySelector("iframe");
  if (existing) attach(existing);
  const observer = new MutationObserver(() => {
    const el = wrapper.querySelector("iframe");
    if (el) { observer.disconnect(); attach(el); }
  });
  if (!existing) observer.observe(wrapper, { childList: true, subtree: true });
  return () => {
    window.clearTimeout(timer);
    observer.disconnect();
    wrapper.querySelector("iframe")?.removeEventListener("load", onLoad);
  };
}, []);

Was this helpful? React with 👍 / 👎

Comment on lines 204 to 218
@@ -218,8 +217,8 @@ function SidebarTabRow({
].join(" ")}
>

@gitar-bot gitar-bot Bot Jun 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Close affordance is a non-focusable span nested in a button

The close affordance was changed from a real <button tabIndex={-1}> to a <span role="button"> to avoid nesting <button> inside the new row <button>. However a <span role="button"> is still interactive content nested inside an interactive <button>, which is invalid per the HTML spec (a button must not contain interactive content) and produces ambiguous semantics for assistive tech. It also has no tabIndex and no keydown handler, so the close action is not keyboard-operable and is announced as a button that can never be reached/activated.

A cleaner structure avoids nesting interactive elements entirely: make the row a non-button container and render the tab-activate target and the close button as sibling interactive elements, or keep the row as a <button> and place the close <button> as a sibling positioned over the row (not a descendant). This restores valid semantics and keyboard access for the close action.

Avoid nested interactive content by making the activate and close controls siblings.:

// Render the row activation and close as sibling buttons instead of nesting:
<div className="group relative flex w-full items-center ...">
  <button type="button" aria-label={title} aria-pressed={isActive}
          onClick={onActivate} title={title} className="flex flex-1 ...">
    {/* favicon + title */}
  </button>
  {!isPinned && !collapsed && (
    <button type="button" aria-label={`Close ${title}`}
            onClick={(e) => { e.stopPropagation(); onClose(); }}
            className="flex-none ...">
      <X size={10} />
    </button>
  )}
</div>

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Jun 20, 2026

Copy link
Copy Markdown

Note

Your trial team has used its Gitar budget, so automatic reviews are paused. Upgrade now to unlock full capacity. Comment "Gitar review" to trigger a review manually.
Learn more about usage limits

Code Review ⚠️ Changes requested 0 resolved / 2 findings

Non-blocking Tailscale detection, improved browser connecting state, and sidebar accessibility refactor are implemented, but the connecting overlay may hang if the iframe load event is missed, and the sidebar close affordance is currently non-focusable.

⚠️ Edge Case: Connecting overlay can get stuck if iframe load is missed

📄 desktop/src/apps/BrowserApp/TabRenderer.tsx:374-388

LiveSessionSlot keeps the connecting overlay (BrowserEmptyState variant="connecting") visible until the neko iframe fires a load event. The overlay is absolute inset-0 with a solid bg-shell-bg-deep background and no pointer-events-none, so while connecting is true it fully obscures and blocks all interaction with the live session.

There is no fallback if the load event is never observed:

  • In the MutationObserver branch (lines 395-401), the observer callback runs asynchronously after the iframe is inserted. If the iframe's load event fires before the callback attaches the listener (e.g. cached/instant load), the event is missed and setConnecting(false) is never called.
  • If the neko iframe never emits a load event for any reason, the overlay stays up permanently, leaving the user staring at the "Starting…" state over a session that is actually live.

Consider adding a safety timeout that clears connecting after a few seconds regardless, and/or checking iframe.contentDocument?.readyState/iframe.complete-style already-loaded state when attaching the listener so an already-fired load is not missed.

Add a timeout fallback so the connecting overlay can never block the live view indefinitely.
useEffect(() => {
  const wrapper = wrapperRef.current;
  if (!wrapper) return;
  const onLoad = () => setConnecting(false);
  // Safety net: clear the overlay even if the load event is missed.
  const timer = window.setTimeout(() => setConnecting(false), 15000);
  const attach = (el: HTMLIFrameElement) => el.addEventListener("load", onLoad);
  const existing = wrapper.querySelector("iframe");
  if (existing) attach(existing);
  const observer = new MutationObserver(() => {
    const el = wrapper.querySelector("iframe");
    if (el) { observer.disconnect(); attach(el); }
  });
  if (!existing) observer.observe(wrapper, { childList: true, subtree: true });
  return () => {
    window.clearTimeout(timer);
    observer.disconnect();
    wrapper.querySelector("iframe")?.removeEventListener("load", onLoad);
  };
}, []);
💡 Quality: Close affordance is a non-focusable span nested in a button

📄 desktop/src/apps/BrowserApp/BrowserSidebar.tsx:204-218

The close affordance was changed from a real <button tabIndex={-1}> to a <span role="button"> to avoid nesting <button> inside the new row <button>. However a <span role="button"> is still interactive content nested inside an interactive <button>, which is invalid per the HTML spec (a button must not contain interactive content) and produces ambiguous semantics for assistive tech. It also has no tabIndex and no keydown handler, so the close action is not keyboard-operable and is announced as a button that can never be reached/activated.

A cleaner structure avoids nesting interactive elements entirely: make the row a non-button container and render the tab-activate target and the close button as sibling interactive elements, or keep the row as a <button> and place the close <button> as a sibling positioned over the row (not a descendant). This restores valid semantics and keyboard access for the close action.

Avoid nested interactive content by making the activate and close controls siblings.
// Render the row activation and close as sibling buttons instead of nesting:
<div className="group relative flex w-full items-center ...">
  <button type="button" aria-label={title} aria-pressed={isActive}
          onClick={onActivate} title={title} className="flex flex-1 ...">
    {/* favicon + title */}
  </button>
  {!isPinned && !collapsed && (
    <button type="button" aria-label={`Close ${title}`}
            onClick={(e) => { e.stopPropagation(); onClose(); }}
            className="flex-none ...">
      <X size={10} />
    </button>
  )}
</div>
🤖 Prompt for agents
Code Review: Non-blocking Tailscale detection, improved browser connecting state, and sidebar accessibility refactor are implemented, but the connecting overlay may hang if the iframe load event is missed, and the sidebar close affordance is currently non-focusable.

1. ⚠️ Edge Case: Connecting overlay can get stuck if iframe load is missed
   Files: desktop/src/apps/BrowserApp/TabRenderer.tsx:374-388

   `LiveSessionSlot` keeps the `connecting` overlay (`BrowserEmptyState variant="connecting"`) visible until the neko iframe fires a `load` event. The overlay is `absolute inset-0` with a solid `bg-shell-bg-deep` background and no `pointer-events-none`, so while `connecting` is true it fully obscures and blocks all interaction with the live session.
   
   There is no fallback if the `load` event is never observed:
   - In the `MutationObserver` branch (lines 395-401), the observer callback runs asynchronously after the iframe is inserted. If the iframe's `load` event fires before the callback attaches the listener (e.g. cached/instant load), the event is missed and `setConnecting(false)` is never called.
   - If the neko iframe never emits a `load` event for any reason, the overlay stays up permanently, leaving the user staring at the "Starting…" state over a session that is actually live.
   
   Consider adding a safety timeout that clears `connecting` after a few seconds regardless, and/or checking `iframe.contentDocument?.readyState`/`iframe.complete`-style already-loaded state when attaching the listener so an already-fired load is not missed.

   Fix (Add a timeout fallback so the connecting overlay can never block the live view indefinitely.):
   useEffect(() => {
     const wrapper = wrapperRef.current;
     if (!wrapper) return;
     const onLoad = () => setConnecting(false);
     // Safety net: clear the overlay even if the load event is missed.
     const timer = window.setTimeout(() => setConnecting(false), 15000);
     const attach = (el: HTMLIFrameElement) => el.addEventListener("load", onLoad);
     const existing = wrapper.querySelector("iframe");
     if (existing) attach(existing);
     const observer = new MutationObserver(() => {
       const el = wrapper.querySelector("iframe");
       if (el) { observer.disconnect(); attach(el); }
     });
     if (!existing) observer.observe(wrapper, { childList: true, subtree: true });
     return () => {
       window.clearTimeout(timer);
       observer.disconnect();
       wrapper.querySelector("iframe")?.removeEventListener("load", onLoad);
     };
   }, []);

2. 💡 Quality: Close affordance is a non-focusable span nested in a button
   Files: desktop/src/apps/BrowserApp/BrowserSidebar.tsx:204-218

   The close affordance was changed from a real `<button tabIndex={-1}>` to a `<span role="button">` to avoid nesting `<button>` inside the new row `<button>`. However a `<span role="button">` is still interactive content nested inside an interactive `<button>`, which is invalid per the HTML spec (a button must not contain interactive content) and produces ambiguous semantics for assistive tech. It also has no `tabIndex` and no keydown handler, so the close action is not keyboard-operable and is announced as a button that can never be reached/activated.
   
   A cleaner structure avoids nesting interactive elements entirely: make the row a non-button container and render the tab-activate target and the close button as sibling interactive elements, or keep the row as a `<button>` and place the close `<button>` as a sibling positioned over the row (not a descendant). This restores valid semantics and keyboard access for the close action.

   Fix (Avoid nested interactive content by making the activate and close controls siblings.):
   // Render the row activation and close as sibling buttons instead of nesting:
   <div className="group relative flex w-full items-center ...">
     <button type="button" aria-label={title} aria-pressed={isActive}
             onClick={onActivate} title={title} className="flex flex-1 ...">
       {/* favicon + title */}
     </button>
     {!isPinned && !collapsed && (
       <button type="button" aria-label={`Close ${title}`}
               onClick={(e) => { e.stopPropagation(); onClose(); }}
               className="flex-none ...">
         <X size={10} />
       </button>
     )}
   </div>

Options

Display: compact → Showing less information.

Comment with these commands to change:

Compact
gitar display:verbose         

Important

Your trial ends in 6 days — upgrade now to keep code review, CI analysis, auto-apply, custom automations, and more.

Was this helpful? React with 👍 / 👎 | Gitar

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@desktop/src/apps/BrowserApp/BrowserSidebar.tsx`:
- Around line 205-211: The span element with role="button" in BrowserSidebar.tsx
that handles the close control is missing keyboard accessibility features. Add
tabIndex={0} to the span to make it focusable for keyboard users, and add an
onKeyDown handler that checks for Enter or Space key presses and calls the
onClose function when detected. This will make the close control accessible to
keyboard users, matching the accessible pattern demonstrated in
SessionBrowser.tsx.

In `@desktop/src/apps/BrowserApp/TabRenderer.tsx`:
- Around line 378-408: The useEffect hook in TabRenderer.tsx has two issues:
first, the empty dependency array means the effect never re-runs when nekoUrl or
streamToken change, so setConnecting(true) is never called when the iframe
reloads for a new session. Second, there's a race condition where the iframe
load event can fire before the listener is attached. To fix this, add nekoUrl
and streamToken to the dependency array and call setConnecting(true) at the
beginning of the effect to reset the connecting state each time it runs.
Additionally, refactor LiveBrowserView to accept an onLoad prop, then pass the
onLoad callback directly to it so the iframe listener can be attached
synchronously during render, eliminating the race condition and MutationObserver
logic entirely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ed32669-976b-46c8-9eb2-8a2b0364408e

📥 Commits

Reviewing files that changed from the base of the PR and between 467b007 and d09625c.

📒 Files selected for processing (3)
  • desktop/src/apps/BrowserApp/BrowserSidebar.tsx
  • desktop/src/apps/BrowserApp/TabRenderer.tsx
  • tinyagentos/worker/browser_container.py

Comment thread desktop/src/apps/BrowserApp/BrowserSidebar.tsx
Comment on lines +378 to +408
useEffect(() => {
// Listen for the neko iframe's load event; once it fires, hide the
// connecting overlay. The iframe is rendered by LiveBrowserView inside
// wrapperRef, so we can find it once it mounts.
const wrapper = wrapperRef.current;
if (!wrapper) return;

const onLoad = () => setConnecting(false);

// The iframe may already be present (SSR / fast mount); attach directly if so.
const iframe = wrapper.querySelector("iframe");
if (iframe) {
iframe.addEventListener("load", onLoad);
return () => iframe.removeEventListener("load", onLoad);
}

// Otherwise observe for the iframe to appear, then attach.
const observer = new MutationObserver(() => {
const el = wrapper.querySelector("iframe");
if (el) {
observer.disconnect();
el.addEventListener("load", onLoad);
}
});
observer.observe(wrapper, { childList: true, subtree: true });
return () => {
observer.disconnect();
const el = wrapper.querySelector("iframe");
if (el) el.removeEventListener("load", onLoad);
};
}, []);

Copy link
Copy Markdown

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the current listener lifecycle and absence/presence of direct iframe onLoad wiring.
rg -nP --type=tsx 'function LiveSessionSlot|const \[connecting|useEffect\(|\], \[\]\)|setConnecting\(true\)' desktop/src/apps/BrowserApp/TabRenderer.tsx -C3
rg -nP --type=tsx 'interface LiveBrowserViewProps|export function LiveBrowserView|<iframe|onLoad' desktop/src/apps/BrowserApp/LiveBrowserView.tsx -C3

Repository: jaylfc/taOS

Length of output: 111


🏁 Script executed:

#!/bin/bash
# Find and examine the TabRenderer.tsx file for the useEffect pattern
cd desktop/src/apps/BrowserApp
echo "=== TabRenderer.tsx useEffect and LiveSessionSlot ==="
cat -n TabRenderer.tsx | sed -n '370,420p'

echo -e "\n=== LiveBrowserView.tsx structure ==="
if [ -f LiveBrowserView.tsx ]; then
  cat -n LiveBrowserView.tsx | head -100
else
  echo "LiveBrowserView.tsx not found in this directory"
  cd ../..
  find . -name "LiveBrowserView.tsx" 2>/dev/null
fi

Repository: jaylfc/taOS

Length of output: 3271


Effect never re-runs when session URL or token changes, leaving connecting state stale.

The empty dependency array [] means useEffect runs only once at mount. If nekoUrl or streamToken change (e.g., switching to a different live session on the same tab), the iframe reloads but setConnecting(true) is never called again, so the overlay won't show for the new session.

Additionally, there is a narrow race window: if the iframe load event fires between the wrapper check (line 382) and the listener attachment (line 390), the event is lost and connecting stays true indefinitely.

Suggested direction

Add nekoUrl and streamToken to the dependency array, and reset connecting when the effect re-runs. For the timing race, expose an onLoad prop on LiveBrowserView and bind it directly to the iframe element:

function LiveSessionSlot({ tabId, nekoUrl, streamToken }: LiveSessionSlotProps) {
  const [connecting, setConnecting] = useState(true);
  const wrapperRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
+   setConnecting(true);
    const wrapper = wrapperRef.current;
    if (!wrapper) return;
    ...
-  }, []);
+  }, [nekoUrl, streamToken]);

And update LiveBrowserView:

interface LiveBrowserViewProps {
  nekoUrl: string;
  streamToken: string;
+ onLoad?: () => void;
}

export function LiveBrowserView({ nekoUrl, streamToken, onLoad }: LiveBrowserViewProps) {
  const src = `${nekoUrl}`#token`=${streamToken}`;

  return (
    <iframe
      title="Full browser"
      src={src}
+     onLoad={onLoad}
      sandbox="allow-scripts allow-forms"
      ...
    />
  );
}
🤖 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 `@desktop/src/apps/BrowserApp/TabRenderer.tsx` around lines 378 - 408, The
useEffect hook in TabRenderer.tsx has two issues: first, the empty dependency
array means the effect never re-runs when nekoUrl or streamToken change, so
setConnecting(true) is never called when the iframe reloads for a new session.
Second, there's a race condition where the iframe load event can fire before the
listener is attached. To fix this, add nekoUrl and streamToken to the dependency
array and call setConnecting(true) at the beginning of the effect to reset the
connecting state each time it runs. Additionally, refactor LiveBrowserView to
accept an onLoad prop, then pass the onLoad callback directly to it so the
iframe listener can be attached synchronously during render, eliminating the
race condition and MutationObserver logic entirely.

jaylfc added 2 commits June 20, 2026 22:35
…idebar a11y (#124)

Three deferred nits from the streamed-browser PRs (#1227/#1228).

- browser_container.py: wrap build_neko_run_args() in asyncio.to_thread()
  at the async call site so the blocking tailscale ip subprocess (and any
  netifaces fallback) runs off the event loop. The sync function stays
  unchanged, keeping all existing tests working.

- TabRenderer.tsx: introduce LiveSessionSlot — a small wrapper that renders
  LiveBrowserView plus a BrowserEmptyState variant="connecting" overlay.
  The overlay dismisses on the neko iframe's first load event, detected via
  a MutationObserver (the iframe mounts inside LiveBrowserView after the
  wrapper mounts). LiveBrowserView is untouched.

- BrowserSidebar.tsx: replace div[role="option"] with native <button> on
  SidebarTabRow. Drops the orphan role="option" (no listbox parent), gains
  keyboard + screen-reader support for free. The close affordance is kept
  as a role="button" span inside the row to avoid nested <button> elements.
  Visual styling and collapsed/icon-only state are unchanged.
- TabRenderer: add 15s safety timeout so the 'connecting' overlay can
  never hang over a live session if the iframe load event is missed
  (already-loaded before listener attach, or never fires cross-origin).
- BrowserSidebar: make the close affordance focusable and keyboard
  operable (tabIndex + Enter/Space handler).
- browser_container: drop the now-misplaced asyncio.to_thread wrap on
  build_neko_run_args; after #1230 it is pure string assembly. The real
  blocking call (host DNS resolution) is now offloaded at the route.
- browser_sessions route: offload the blocking _connecting_host_ip DNS
  lookup via asyncio.to_thread so it does not stall the event loop.

Note: _detect_tailscale_ip and _resolve_connecting_ip in
browser_container.py are now dead code (orphaned by #1230); left in
place for a separate cleanup.
@jaylfc jaylfc force-pushed the fix/browser-124-nits branch from d09625c to f340043 Compare June 20, 2026 21:40
@jaylfc jaylfc merged commit 53f893a into dev Jun 20, 2026
1 of 3 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in TinyAgentOS Roadmap Jun 20, 2026
@jaylfc jaylfc deleted the fix/browser-124-nits branch June 21, 2026 12:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant