Skip to content

feat(browser): PR 5 — profile switcher, discard policy, Reader mode#304

Merged
jaylfc merged 15 commits into
masterfrom
feat/browser-pr-5-profile-switcher-discard-reader
May 4, 2026
Merged

feat(browser): PR 5 — profile switcher, discard policy, Reader mode#304
jaylfc merged 15 commits into
masterfrom
feat/browser-pr-5-profile-switcher-discard-reader

Conversation

@jaylfc
Copy link
Copy Markdown
Owner

@jaylfc jaylfc commented May 4, 2026

Summary

Three quality-of-life upgrades that turn PR 4's working browser into a daily driver:

  1. Profile switcher + manager — dropdown chip in chrome to switch active profile per window, modal for create/rename/delete with cookie-cascade warning. Active-profile delete is disabled in-window; if a profile is deleted while another window is using it, that window auto-recovers to the first remaining profile.
  2. Discard policy with live-exclusion — the existing PR 4 idle/cap discard scheduler now exempts tabs that are playing audio/video, have active form input with non-empty value, or have selected files in an upload input. In-app settings panel exposes the discard timeout (1–60 min, default 10), live-tab cap (1–50, default 12), and default search engine (DDG / Google / Bing).
  3. Reader mode — Readability extract endpoint (/api/desktop/browser/extract) plus a URL-bar toggle that appears when an article has >200 words. Article view renders DOMPurify-sanitised HTML with font-size and serif/sans-serif controls. Lazy: extract fires on address-bar focus, not on every navigation. Stale-fetch guard discards a result if the user navigated away mid-fetch.

Per-(window, profile) tab snapshots ride along: switching profiles saves the current tab set under the old profile and restores any prior snapshot for the new profile.

Files

  • Backend: profile CRUD routes + cascade delete (profile.py, profile_routes.py, store.py), Readability extract endpoint (extract.py).
  • Frontend: ProfileSwitcher.tsx, ProfileManager.tsx, SettingsPanel.tsx, ReaderMode.tsx, live-exclusion.ts, browser-settings-store.ts, plus browser-extract-api.ts and browser-profile-api.ts clients.
  • Modifies: Chrome.tsx (gear icon + chip), AddressBar.tsx (Reader toggle + lazy extract + search-engine wired through), TabRenderer.tsx (live-exclusion in scheduler, ReaderMode renders over hidden iframe), browser-store.ts (switchProfile, setTabReader, reader-state reset on navigateTab / goBack / goForward), Tab type extensions.

Test plan

  • Backend: `pytest tests/routes/desktop_browser/ -q` → 162 passed
  • Frontend: `cd desktop && npx vitest run` → 483 passed across 90 files
  • Manual: switch profile chip → tabs change → switch back → original tabs restored
  • Manual: delete a profile → cookies for that profile gone, any other window on that profile auto-switches to fallback
  • Manual: open a video tab, switch away, wait past discard timeout → video tab stays live (excluded)
  • Manual: long-form article (Wikipedia) → Reader toggle appears, click → sanitised article view; toggling off restores iframe with scroll position intact
  • Manual: list-heavy homepage → Reader toggle absent
  • Manual: SettingsPanel slider change → next scheduler tick uses new discard timeout

Deferred (follow-ups)

  • Multi-window profile-delete server push — current fix is client-side scan inside ProfileManager; a proper fix wants the server to broadcast `profile.deleted` to all sessions for the user.
  • Stricter DOMPurify allowlist — current config (`USE_PROFILES: { html: true }`) closes the XSS surface but still permits ``, ``, `<iframe>` etc. Article-only allowlist would be tighter.
  • Snapshot reader-extract eviction — `switchProfile` snapshots include cached `readerExtract.html`; large articles × many profiles could grow in-memory state. Consider stripping `readerExtract` on snapshot and re-extracting on demand after restore.

Spec / plan references

  • Spec: `docs/superpowers/specs/2026-05-03-browser-app-v2-design.md`
  • Plan: `docs/superpowers/plans/2026-05-03-browser-app-v2-pr-5-profile-switcher-discard-reader.md`

Summary by CodeRabbit

  • New Features

    • Reader mode: one-click reader view with sanitized article rendering, font controls, and lazy per-URL extraction
    • Profiles: create/rename/delete profiles with colors, plus profile switching that preserves/restores per-profile tabs
    • Settings panel: configure discard timeout, max live tabs, and default search engine (searches respect chosen engine)
    • Smart tab preservation: detect playing media, active forms, uploads to keep tabs live
  • Tests

    • Expanded test coverage across reader, profiles, settings, extraction, live-exclusion, and tab rendering features

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Warning

Rate limit exceeded

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

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

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

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

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

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

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: edde4bd9-7e6d-4f35-ba9f-bb5aa75da024

📥 Commits

Reviewing files that changed from the base of the PR and between cf22778 and f3117c7.

📒 Files selected for processing (5)
  • desktop/src/apps/BrowserApp/Chrome.test.tsx
  • desktop/src/apps/BrowserApp/ProfileManager.test.tsx
  • tests/routes/desktop_browser/test_extract.py
  • tinyagentos/routes/desktop_browser/extract.py
  • tinyagentos/routes/desktop_browser/profile.py
📝 Walkthrough

Walkthrough

Adds reader-mode extraction/UI, profile CRUD and switching (with per-profile tab snapshots), configurable settings and discard heuristics, iframe live-exclusion detection, backend extract and profile endpoints, and associated tests across frontend, store, and server code.

Changes

Reader Mode

Layer / File(s) Summary
Data Shape / Types
desktop/src/apps/BrowserApp/types.ts
Added ReaderExtract and Tab reader fields: readerAvailable?, readerActive?, `readerExtract?: ReaderExtract
Backend Extract API
tinyagentos/routes/desktop_browser/extract.py, pyproject.toml
New /api/desktop/browser/extract endpoint and extract_readable() using readability-lxml; pyproject adds readability-lxml dependency.
Client API
desktop/src/lib/browser-extract-api.ts
Added extractReadable(profileId, url) and ExtractResult type; fetches endpoint with credentials and returns parsed result or null on error.
AddressBar Integration
desktop/src/apps/BrowserApp/AddressBar.tsx
On focus, lazily calls extractReadable once per-URL (guards via readerAvailable and inflightUrlRef), sets readerAvailable based on word count (exported READER_MIN_WORD_COUNT = 200), and shows a reader toggle that flips readerActive via setTabReader. Search resolution uses searchUrlFor() from settings.
Reader UI
desktop/src/apps/BrowserApp/ReaderMode.tsx
New ReaderMode component that sanitizes readerExtract.html with DOMPurify, renders title/source, offers font-size/family controls, and calls setTabReader(..., { readerActive: false }) to exit.
Tab Rendering & Store
desktop/src/apps/BrowserApp/TabRenderer.tsx, desktop/src/stores/browser-store.ts
TabRenderer renders ReaderMode overlay and hides iframe when readerActive. Store gains setTabReader() and navigation flows now clear reader fields on URL changes.
Tests
desktop/src/apps/BrowserApp/AddressBar.test.tsx, ReaderMode.test.tsx, desktop/src/lib/browser-extract-api.test.ts, tests/routes/desktop_browser/test_extract.py, desktop/src/apps/BrowserApp/TabRenderer.test.tsx
Coverage for extraction trigger, stale-result discard, ReaderMode rendering/controls/exit, API request behavior, and TabRenderer reader integration.

Profile Management

Layer / File(s) Summary
Data Shape / Types
desktop/src/apps/BrowserApp/types.ts
Added SavedProfileTabs and _savedTabsByProfile?: Record<string, SavedProfileTabs> on BrowserWindowState for per-profile snapshots.
Backend Store & Helpers
tinyagentos/routes/desktop_browser/store.py, tinyagentos/routes/desktop_browser/profile.py, tinyagentos/routes/desktop_browser/schema.py
Store: add_profile now returns bool, added update_profile, claim_profile_init, guarded delete_profile, and cookie deletion API. Profile helpers: _slugify, create_profile, rename_profile, delete_profile_cascade. New profile_init table for idempotent bootstrapping.
Route Handlers
tinyagentos/routes/desktop_browser/profile_routes.py
GET/POST/PATCH/DELETE endpoints for /api/desktop/browser/profiles with auth, default-profile seeding, validation, and cascade behavior.
Client API
desktop/src/lib/browser-profile-api.ts
Added Profile type and CRUD wrappers: listProfiles, createProfile, renameProfile, deleteProfile.
UI Components
desktop/src/apps/BrowserApp/ProfileManager.tsx, ProfileSwitcher.tsx
ProfileManager modal supports list, rename, delete (with cookie-warning confirm), and create (name + color). ProfileSwitcher menu lists profiles, marks active, supports quick create and optional "Manage profiles…" action.
Chrome Integration & Store
desktop/src/apps/BrowserApp/Chrome.tsx, desktop/src/stores/browser-store.ts
Chrome toggles overlays (ProfileSwitcher, ProfileManager, SettingsPanel) and loads profiles via listProfiles(). Store adds switchProfile() that snapshots/restores tabs per (window, profileId) in _savedTabsByProfile.
Tests
desktop/src/apps/BrowserApp/ProfileManager.test.tsx, ProfileSwitcher.test.tsx, desktop/src/stores/browser-store.test.ts, tests/routes/desktop_browser/test_profile_routes.py
Component and API tests covering listing, create/rename/delete flows, collision/retry behavior, delete cascades, orphan recovery, and auth/isolation.

Settings and Tab Discard / Live-Exclusion

Layer / File(s) Summary
Settings Store
desktop/src/stores/browser-settings-store.ts
New Zustand persisted store with discardTimeoutMs (clamped 1–60 min), maxLiveTabs (clamped 1–50), searchEngine selection, SEARCH_ENGINES, and searchUrlFor() helper.
Live-Exclusion Detection
desktop/src/apps/BrowserApp/live-exclusion.ts
detectLiveExclusion(iframe, isPinned) inspects iframe contentDocument and returns "pinned", "video", "audio", "form-active", "upload", or undefined with a fixed priority.
TabRenderer Integration
desktop/src/apps/BrowserApp/TabRenderer.tsx
Scheduler queries detectLiveExclusion() per tab, writes liveExclusion with setTabLiveExclusion(), excludes such tabs from idle discard and from maxLiveTabs overflow trimming (now driven by settings store). Rendering preserves iframe in DOM when reader shown (display:none).
Settings UI
desktop/src/apps/BrowserApp/SettingsPanel.tsx
Popover dialog editing discard timeout (minutes slider), max live tabs spinner, and default search engine select; supports click-outside and Escape to dismiss.
Chrome Wiring
desktop/src/apps/BrowserApp/Chrome.tsx
Settings button toggles SettingsPanel; opening one overlay closes others to avoid overlap.
Tests
desktop/src/apps/BrowserApp/SettingsPanel.test.tsx, TabRenderer.test.tsx, desktop/src/apps/BrowserApp/live-exclusion.test.ts, desktop/src/stores/browser-settings-store.test.ts
Tests for settings UI/store behavior, discard scheduling wired to discardTimeoutMs, and live-exclusion detection scenarios (media, form, upload).

Router Registration & Misc Backend

Layer / File(s) Summary
Router Imports
tinyagentos/routes/desktop_browser/__init__.py
Added side-effect imports to register profile_routes and extract modules on the shared APIRouter.
Tests (server)
tests/routes/desktop_browser/test_extract.py, tests/routes/desktop_browser/test_profile_routes.py
Endpoint tests for extract (auth, SSRF, fetch/extract), and comprehensive profile route tests (auth, list/create/patch/delete, collision and isolation cases).

Sequence Diagram(s)

sequenceDiagram
    participant AddressBar as AddressBar (Client)
    participant BrowserStore as Browser Store
    participant Backend as /api/desktop/browser/extract
    participant ReaderMode as ReaderMode UI

    AddressBar->>BrowserStore: read activeTab, readerAvailable?
    Note right of AddressBar: on input focus\nwhen readerAvailable === undefined
    AddressBar->>Backend: GET /api/.../extract?profile_id=&url=
    Backend-->>AddressBar: ExtractResult (title, html, text, word_count)
    AddressBar->>BrowserStore: setTabReader(readerAvailable: true/false, readerExtract?)
    BrowserStore-->>ReaderMode: updated tab state (readerAvailable/readerExtract)
    ReaderMode-->>AddressBar: renders toggle/button (readerAvailable true)
    AddressBar->>BrowserStore: setTabReader(readerActive: toggle)
    BrowserStore-->>ReaderMode: show/hide reader UI
Loading

Note: rectangles use default styling; diagram focuses on high-level flow.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

"🐰 I hopped through code both night and day,
Found articles tidy in a neat display,
Profiles snug in their colorful nook,
Settings tuned with a careful look,
Hooray — small changes, big browsing play!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.43% 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 specifically describes the three main features added: profile switcher, discard policy, and Reader mode—matching the primary changes across the substantial changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/browser-pr-5-profile-switcher-discard-reader

@kilo-code-bot
Copy link
Copy Markdown

kilo-code-bot Bot commented May 4, 2026

Code Review Summary

Status: 7 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 5
Issue Details (click to expand)

CRITICAL

File Line Issue
desktop/src/apps/BrowserApp/SettingsPanel.tsx 1920 Potential runtime error due to invalid number input handling

WARNING

File Line Issue
desktop/src/apps/BrowserApp/AddressBar.tsx 229 Missing error handling in AddressBar extract logic

SUGGESTION

File Line Issue
desktop/src/apps/BrowserApp/ReaderMode.tsx 1531 Potential XSS risk in ReaderMode
desktop/src/apps/BrowserApp/ReaderMode.tsx 1538 Edge case in source domain extraction
desktop/src/apps/BrowserApp/ProfileManager.tsx 962 Missing input validation in ProfileManager create form
desktop/src/apps/BrowserApp/ProfileManager.tsx 838 Potential race condition in profile deletion
desktop/src/apps/BrowserApp/SettingsPanel.tsx 1950 No validation for search engine in SettingsPanel
Other Observations (issues in diff but lines not resolvable)

Issues found in changed code that cannot receive inline comments due to line resolution issues:

File Line Issue
desktop/src/apps/BrowserApp/SettingsPanel.tsx 1920 Potential runtime error due to invalid number input handling
desktop/src/apps/BrowserApp/AddressBar.tsx 229 Missing error handling in AddressBar extract logic
desktop/src/apps/BrowserApp/ReaderMode.tsx 1531 Potential XSS risk in ReaderMode
desktop/src/apps/BrowserApp/ReaderMode.tsx 1538 Edge case in source domain extraction
desktop/src/apps/BrowserApp/ProfileManager.tsx 962 Missing input validation in ProfileManager create form
desktop/src/apps/BrowserApp/ProfileManager.tsx 838 Potential race condition in profile deletion
desktop/src/apps/BrowserApp/SettingsPanel.tsx 1950 No validation for search engine in SettingsPanel
Files Reviewed (33 files)
  • desktop/src/apps/BrowserApp/AddressBar.test.tsx - 0 issues
  • desktop/src/apps/BrowserApp/AddressBar.tsx - 1 issues
  • desktop/src/apps/BrowserApp/Chrome.test.tsx - 0 issues
  • desktop/src/apps/BrowserApp/Chrome.tsx - 0 issues
  • desktop/src/apps/BrowserApp/ProfileManager.test.tsx - 0 issues
  • desktop/src/apps/BrowserApp/ProfileManager.tsx - 2 issues
  • desktop/src/apps/BrowserApp/ProfileSwitcher.test.tsx - 0 issues
  • desktop/src/apps/BrowserApp/ProfileSwitcher.tsx - 0 issues
  • desktop/src/apps/BrowserApp/ReaderMode.test.tsx - 0 issues
  • desktop/src/apps/BrowserApp/ReaderMode.tsx - 2 issues
  • desktop/src/apps/BrowserApp/SettingsPanel.test.tsx - 0 issues
  • desktop/src/apps/BrowserApp/SettingsPanel.tsx - 2 issues
  • desktop/src/apps/BrowserApp/TabRenderer.tsx - 0 issues
  • desktop/src/apps/BrowserApp/live-exclusion.ts - 0 issues
  • desktop/src/apps/BrowserApp/types.ts - 0 issues
  • desktop/src/lib/browser-extract-api.ts - 0 issues
  • desktop/src/lib/browser-profile-api.ts - 0 issues
  • desktop/src/lib/browser-settings-store.ts - 0 issues
  • desktop/src/stores/browser-settings-store.ts - 0 issues
  • desktop/src/stores/browser-store.ts - 0 issues
  • pyproject.toml - 0 issues
  • tests/routes/desktop_browser/test_extract.py - 0 issues
  • tests/routes/desktop_browser/test_profile_routes.py - 0 issues
  • tinyagentos/routes/desktop_browser/__init__.py - 0 issues
  • tinyagentos/routes/desktop_browser/extract.py - 0 issues
  • tinyagentos/routes/desktop_browser/profile.py - 0 issues
  • tinyagentos/routes/desktop_browser/profile_routes.py - 0 issues
  • tinyagentos/routes/desktop_browser/schema.py - 0 issues
  • tinyagentos/routes/desktop_browser/store.py - 0 issues

Reviewed by grok-code-fast-1:optimized:free · 318,422 tokens

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (3)
tinyagentos/routes/desktop_browser/store.py (1)

156-170: ⚡ Quick win

Address Ruff S608 on the f-string SQL construction.

The current sets list only ever holds the two hardcoded literals "name = ?" and "color = ?" — no user input reaches the query template, so there is no real injection risk. However, Ruff S608 triggers on the f-string pattern and will fail CI if the rule is treated as an error. The simplest fix is a targeted # noqa suppression; alternatively, since there are only two optional fields, the four possible queries can be expressed explicitly, which also removes the need for the diagnostic.

🛠️ Option A — inline suppression
         cursor = await self._db.execute(
-            f"UPDATE profiles SET {', '.join(sets)} "
-            f"WHERE user_id = ? AND profile_id = ?",
+            f"UPDATE profiles SET {', '.join(sets)} "  # noqa: S608
+            "WHERE user_id = ? AND profile_id = ?",
             params,
         )
🛠️ Option B — eliminate the f-string entirely
-        # Build dynamic SET clause
-        sets: list[str] = []
-        params: list[object] = []
-        if name is not None:
-            sets.append("name = ?")
-            params.append(name)
-        if color is not None:
-            sets.append("color = ?")
-            params.append(color)
-        params.extend([user_id, profile_id])
-        cursor = await self._db.execute(
-            f"UPDATE profiles SET {', '.join(sets)} "
-            f"WHERE user_id = ? AND profile_id = ?",
-            params,
-        )
+        if name is not None and color is not None:
+            sql = "UPDATE profiles SET name = ?, color = ? WHERE user_id = ? AND profile_id = ?"
+            p: list[object] = [name, color, user_id, profile_id]
+        elif name is not None:
+            sql = "UPDATE profiles SET name = ? WHERE user_id = ? AND profile_id = ?"
+            p = [name, user_id, profile_id]
+        else:
+            sql = "UPDATE profiles SET color = ? WHERE user_id = ? AND profile_id = ?"
+            p = [color, user_id, profile_id]
+        cursor = await self._db.execute(sql, p)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/routes/desktop_browser/store.py` around lines 156 - 170, The
f-string used to build the UPDATE SQL in the method that constructs sets and
params (the sets list, params list, and the self._db.execute call building
f"UPDATE profiles SET {', '.join(sets)} ...") triggers Ruff S608; fix by adding
a targeted inline suppression on that f-string (append "  # noqa: S608") or,
alternatively, eliminate the f-string entirely by composing the query with plain
string concatenation/format-free join (e.g., base = "UPDATE profiles SET " + ",
".join(sets) + " WHERE user_id = ? AND profile_id = ?" and pass params to
self._db.execute) so the code no longer trips S608 while preserving the current
parameterized behavior.
desktop/src/apps/BrowserApp/ProfileManager.test.tsx (1)

202-294: ⚡ Quick win

Test renders component twice, creating potential flakiness.

The test renders ProfileManager at line 225, then re-configures the mock (lines 240-266), then renders again at line 269 without unmounting the first. This leaves two instances in the DOM, requiring getAllByText("Work")[0] and similar array indexing to select the correct elements. Consider unmounting the first render or restructuring to avoid the double-render pattern.

♻️ Suggested fix: remove first render or unmount before re-render
-    // Render ProfileManager as if the user in window-a opened it (active = personal)
-    render(
-      <ProfileManager
-        activeProfileId="personal"
-        onClose={() => {}}
-      />,
-    );
-
-    // Wait for the initial profile list (which initially has personal + work)
-    // But we've already set up fetch to return only personal, so wait for Work to appear
-    // via the initial load mock — we need to override the initial load too.
-    // Let's wait for the list to settle: the first GET returns personal only (no work).
-    // Because the fetch is mocked to return personal-only for ALL GETs, the list
-    // will show only Personal. We won't see Work in the UI.
-    // To properly test the flow, we need Work in the initial list.
-    // Re-mock: first GET returns both, DELETE succeeds, second GET returns only personal.
     global.fetch = vi.fn()
       .mockImplementationOnce(() =>
         // ... mock sequence
       );

-    // Re-render with correct mock sequence
-    const { unmount } = render(
+    const { unmount } = render(
       <ProfileManager
         activeProfileId="personal"
         onClose={() => {}}
       />,
     );

-    await waitFor(() => screen.getAllByText("Work")[0]);
+    await waitFor(() => screen.getByText("Work"));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/BrowserApp/ProfileManager.test.tsx` around lines 202 - 294,
The test currently renders ProfileManager twice (two render(...) calls) causing
duplicate DOM instances and flakiness; remove the first render or call the first
render's unmount() before setting up the second mock sequence and re-rendering
so only one ProfileManager instance exists; specifically adjust the initial
render call that precedes the mockImplementationOnce sequence (and its
associated render) to be removed or unmounted, keep the later render(...) that
uses the three-step fetch mock, and ensure you only interact with elements from
that single mounted ProfileManager (references: ProfileManager component,
render(), unmount(), and the fetch mock setup).
desktop/src/apps/BrowserApp/ProfileSwitcher.tsx (1)

32-35: 💤 Low value

Consider adding error handling for profile loading.

If listProfiles() fails, profiles remains null and the UI shows "Loading…" indefinitely. A brief error state would improve UX, though this is acceptable for MVP.

♻️ Optional: Add error state
 const [profiles, setProfiles] = useState<Profile[] | null>(null);
+const [loadError, setLoadError] = useState(false);
 const [creating, setCreating] = useState(false);
 ...
 useEffect(() => {
-  listProfiles().then(setProfiles);
+  listProfiles()
+    .then(setProfiles)
+    .catch(() => setLoadError(true));
 }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/BrowserApp/ProfileSwitcher.tsx` around lines 32 - 35, The
profile loading hook in ProfileSwitcher currently calls listProfiles() inside
useEffect and only sets profiles via setProfiles, so failures leave profiles
null and the UI stuck on "Loading…". Update the effect to handle errors: call
listProfiles().then(setProfiles).catch(err => { setProfiles([]); setError(err)
}) and add a local error state (e.g., const [error, setError] =
useState<Error|null>(null)) in the ProfileSwitcher component; then render a
brief error message or retry button when error is non-null (instead of the
perpetual loading state) so the UI recovers when listProfiles() fails.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@desktop/src/apps/BrowserApp/Chrome.tsx`:
- Around line 95-98: The profile manager modal's open state (managerOpen /
setManagerOpen) isn't being closed when other overlays open; update the
overlay-toggle handlers (the onClick that calls setSwitcherOpen and
setSettingsOpen, and the similar handler at the other location) to also call
setManagerOpen(false) so the manager is closed before opening the switcher or
settings. Ensure both handlers explicitly close the manager (e.g.,
setManagerOpen(false)) prior to toggling their respective panels to prevent
overlapping modals and focus conflicts.

In `@desktop/src/apps/BrowserApp/ProfileManager.tsx`:
- Around line 48-51: The async handlers reload, handleRename, handleDelete, and
handleCreate currently call
listProfiles/createProfile/renameProfile/deleteProfile and await results without
try/catch, so wrap the await-containing body of each function in a try/catch; on
error call setError(error?.message || String(error)) and optionally clear any
success state (e.g., keep existing setProfiles/update UI only on success), and
rethrow or return gracefully as needed. Ensure you still handle the existing
null/false return branches after the try block (e.g., only run the current
setProfiles/follow-up logic when API call succeeds) so thrown exceptions are
surfaced to the UI via setError.
- Around line 119-121: The confirmation overlay uses absolute inset-0 but the
card container in ProfileManager.tsx (the <div> with className "bg-shell-surface
rounded-md shadow-xl border border-shell-border w-[420px] max-w-full
max-h-[80vh] flex flex-col" and onClick={(e) => e.stopPropagation()}) is not
positioned, so the overlay anchors to the viewport; update that container to be
positioned (add the "relative" class) so the confirmation overlay (around lines
~270–274) will be sized and rounded relative to the card instead of the full
screen.
- Around line 57-58: The error banner can persist because setError(null) is
never called at the start of the handlers; update handleRename, handleDelete,
and handleCreate to call setError(null) immediately as the first statement in
each function so any previous error is cleared before attempting the new
operation (refer to the handleRename, handleDelete, handleCreate function
declarations and the setError state setter).
- Around line 57-69: The rename handler (handleRename) can be re-entered when
setEditingId(null) unmounts the input and triggers onBlur, causing a double call
to renameProfile; add an in-flight re-entry guard (e.g., a ref or state like
isRenamingRef or isRenaming) checked at the top of handleRename and set before
awaiting renameProfile and cleared in a finally block to prevent duplicate
requests, update both handleRename and the corresponding onBlur handler to
respect this guard so the blur-triggered call no-ops if a rename is already in
progress.
- Around line 113-118: The dialog element in ProfileManager (the <div
role="dialog" aria-label="Manage profiles" ... onClick={onClose}>) is missing
aria-modal="true"; update that div to include aria-modal="true" so assistive
technologies treat the rest of the document as inert while the modal is open and
preserve proper dialog semantics.

In `@desktop/src/stores/browser-settings-store.test.ts`:
- Around line 1-6: Add the missing type import for SearchEngine to the test
imports so the TypeScript-only assertion "as SearchEngine" compiles under tsc
--noEmit; update the import line that currently imports useBrowserSettingsStore,
searchUrlFor, and SEARCH_ENGINES from "./browser-settings-store" to also import
the SearchEngine type (using a type import if desired) to resolve the "Cannot
find name 'SearchEngine'" error.

In `@tests/routes/desktop_browser/test_profile_routes.py`:
- Around line 51-59: The test_post_appends_suffix_on_collision currently only
captures the second POST response and allows either "personal-2" or
"personal-3"; change it to capture both POST responses and assert
deterministically that the first POST returns profile_id "personal-2" and the
second returns "personal-3" (referencing the test name
test_post_appends_suffix_on_collision and the "profile_id" field from the JSON
bodies of the two POSTs to /api/desktop/browser/profiles) so the collision
handling is strictly verified.

In `@tinyagentos/routes/desktop_browser/profile.py`:
- Around line 200-212: The pre-check in profile deletion is racy: remove the
separate snapshot checks in routes/desktop_browser/profile.py (the code that
calls browser_store.list_profiles and the len(profiles) guard) and instead
enforce the "cannot delete last profile" invariant atomically inside the store
layer by adding or updating a transactional store method (e.g.
browser_store.delete_profile or a new browser_store.delete_profile_if_not_last)
to check the remaining profile count and raise LastProfileError within the same
DB transaction that performs the delete; update the route to call that atomic
store method and handle LastProfileError as before.
- Around line 45-67: The bootstrap currently does a read-then-write causing
races; wrap the claim-and-seed in a single DB transaction so only the
transaction that actually inserts the init marker seeds defaults. Use store._db
transaction semantics (begin/commit/rollback or a transaction context) and
perform "INSERT OR IGNORE INTO profile_init (user_id, initialized_at) VALUES (?,
?)" inside that transaction, then check whether the insert affected a row (e.g.,
cursor.rowcount or SELECT changes()/re-query profile_init for user_id) — only
call store.add_profile for entries in _DEFAULTS when the insert indicates we won
the init claim; finally commit the transaction (or rollback on error). Ensure
you reference store._db, profile_init, add_profile, and _DEFAULTS while making
these changes.
- Around line 154-161: The rename route currently forwards name unchanged to
store.update_profile so blank or whitespace-only names can slip through; trim
the incoming name (e.g., name = name.strip() if name is not None), validate that
the trimmed name is non-empty and raise a ValueError if it is blank, then pass
the normalized name to store.update_profile (reference: rename_profile,
update_profile, create_profile for consistent behavior). Also catch ValueError
raised during validation or by update_profile in the route and convert it into
an HTTP 400 response (HTTPException or your framework's equivalent) so clients
receive a proper bad-request error instead of a 500.

---

Nitpick comments:
In `@desktop/src/apps/BrowserApp/ProfileManager.test.tsx`:
- Around line 202-294: The test currently renders ProfileManager twice (two
render(...) calls) causing duplicate DOM instances and flakiness; remove the
first render or call the first render's unmount() before setting up the second
mock sequence and re-rendering so only one ProfileManager instance exists;
specifically adjust the initial render call that precedes the
mockImplementationOnce sequence (and its associated render) to be removed or
unmounted, keep the later render(...) that uses the three-step fetch mock, and
ensure you only interact with elements from that single mounted ProfileManager
(references: ProfileManager component, render(), unmount(), and the fetch mock
setup).

In `@desktop/src/apps/BrowserApp/ProfileSwitcher.tsx`:
- Around line 32-35: The profile loading hook in ProfileSwitcher currently calls
listProfiles() inside useEffect and only sets profiles via setProfiles, so
failures leave profiles null and the UI stuck on "Loading…". Update the effect
to handle errors: call listProfiles().then(setProfiles).catch(err => {
setProfiles([]); setError(err) }) and add a local error state (e.g., const
[error, setError] = useState<Error|null>(null)) in the ProfileSwitcher
component; then render a brief error message or retry button when error is
non-null (instead of the perpetual loading state) so the UI recovers when
listProfiles() fails.

In `@tinyagentos/routes/desktop_browser/store.py`:
- Around line 156-170: The f-string used to build the UPDATE SQL in the method
that constructs sets and params (the sets list, params list, and the
self._db.execute call building f"UPDATE profiles SET {', '.join(sets)} ...")
triggers Ruff S608; fix by adding a targeted inline suppression on that f-string
(append "  # noqa: S608") or, alternatively, eliminate the f-string entirely by
composing the query with plain string concatenation/format-free join (e.g., base
= "UPDATE profiles SET " + ", ".join(sets) + " WHERE user_id = ? AND profile_id
= ?" and pass params to self._db.execute) so the code no longer trips S608 while
preserving the current parameterized behavior.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 62996908-ee5b-4c01-9ea1-9de26214f8bb

📥 Commits

Reviewing files that changed from the base of the PR and between 1dd64af and f5caaca.

📒 Files selected for processing (33)
  • desktop/src/apps/BrowserApp/AddressBar.test.tsx
  • desktop/src/apps/BrowserApp/AddressBar.tsx
  • desktop/src/apps/BrowserApp/Chrome.tsx
  • desktop/src/apps/BrowserApp/ProfileManager.test.tsx
  • desktop/src/apps/BrowserApp/ProfileManager.tsx
  • desktop/src/apps/BrowserApp/ProfileSwitcher.test.tsx
  • desktop/src/apps/BrowserApp/ProfileSwitcher.tsx
  • desktop/src/apps/BrowserApp/ReaderMode.test.tsx
  • desktop/src/apps/BrowserApp/ReaderMode.tsx
  • desktop/src/apps/BrowserApp/SettingsPanel.test.tsx
  • desktop/src/apps/BrowserApp/SettingsPanel.tsx
  • desktop/src/apps/BrowserApp/TabRenderer.test.tsx
  • desktop/src/apps/BrowserApp/TabRenderer.tsx
  • desktop/src/apps/BrowserApp/live-exclusion.test.ts
  • desktop/src/apps/BrowserApp/live-exclusion.ts
  • desktop/src/apps/BrowserApp/types.ts
  • desktop/src/lib/browser-extract-api.test.ts
  • desktop/src/lib/browser-extract-api.ts
  • desktop/src/lib/browser-profile-api.test.ts
  • desktop/src/lib/browser-profile-api.ts
  • desktop/src/stores/browser-settings-store.test.ts
  • desktop/src/stores/browser-settings-store.ts
  • desktop/src/stores/browser-store.test.ts
  • desktop/src/stores/browser-store.ts
  • pyproject.toml
  • tests/routes/desktop_browser/test_extract.py
  • tests/routes/desktop_browser/test_profile_routes.py
  • tinyagentos/routes/desktop_browser/__init__.py
  • tinyagentos/routes/desktop_browser/extract.py
  • tinyagentos/routes/desktop_browser/profile.py
  • tinyagentos/routes/desktop_browser/profile_routes.py
  • tinyagentos/routes/desktop_browser/schema.py
  • tinyagentos/routes/desktop_browser/store.py

Comment thread desktop/src/apps/BrowserApp/Chrome.tsx
Comment thread desktop/src/apps/BrowserApp/ProfileManager.tsx
Comment thread desktop/src/apps/BrowserApp/ProfileManager.tsx
Comment thread desktop/src/apps/BrowserApp/ProfileManager.tsx
Comment thread desktop/src/apps/BrowserApp/ProfileManager.tsx
Comment thread desktop/src/stores/browser-settings-store.test.ts
Comment on lines +51 to +59
async def test_post_appends_suffix_on_collision(self, client):
await client.post(
"/api/desktop/browser/profiles", json={"name": "Personal", "color": "#000000"},
)
body = (await client.post(
"/api/desktop/browser/profiles", json={"name": "Personal", "color": "#111111"},
)).json()
# Original "personal" exists from defaults; first POST gets "personal-2"
assert body["profile_id"] in {"personal-2", "personal-3"}
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 | 🟡 Minor | ⚡ Quick win

Tighten this collision assertion.

These requests are sequential, so after the defaults already own personal, the first POST here should take personal-2 and the second should deterministically return personal-3. Allowing either suffix makes the test pass even if collision handling regresses.

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

In `@tests/routes/desktop_browser/test_profile_routes.py` around lines 51 - 59,
The test_post_appends_suffix_on_collision currently only captures the second
POST response and allows either "personal-2" or "personal-3"; change it to
capture both POST responses and assert deterministically that the first POST
returns profile_id "personal-2" and the second returns "personal-3" (referencing
the test name test_post_appends_suffix_on_collision and the "profile_id" field
from the JSON bodies of the two POSTs to /api/desktop/browser/profiles) so the
collision handling is strictly verified.

Comment thread tinyagentos/routes/desktop_browser/profile.py Outdated
Comment thread tinyagentos/routes/desktop_browser/profile.py
Comment thread tinyagentos/routes/desktop_browser/profile.py Outdated
@jaylfc
Copy link
Copy Markdown
Owner Author

jaylfc commented May 4, 2026

@coderabbitai full review — please re-review commit 74c3f85 which addresses the previous round (especially the atomic SQL changes for default-profile seeding race and last-profile delete race in tinyagentos/routes/desktop_browser/profile.py + store.py).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read the key files under review
echo "=== tinyagentos/routes/desktop_browser/profile.py ==="
cat tinyagentos/routes/desktop_browser/profile.py

echo ""
echo "=== tinyagentos/routes/desktop_browser/store.py ==="
cat tinyagentos/routes/desktop_browser/store.py

echo ""
echo "=== tinyagentos/routes/desktop_browser/profile_routes.py ==="
cat tinyagentos/routes/desktop_browser/profile_routes.py

echo ""
echo "=== tinyagentos/routes/desktop_browser/schema.py ==="
cat tinyagentos/routes/desktop_browser/schema.py

Length of output: 31856


🏁 Script executed:

#!/bin/bash
echo "=== tinyagentos/routes/desktop_browser/extract.py ==="
cat tinyagentos/routes/desktop_browser/extract.py

echo ""
echo "=== desktop/src/apps/BrowserApp/AddressBar.tsx ==="
cat desktop/src/apps/BrowserApp/AddressBar.tsx

echo ""
echo "=== desktop/src/apps/BrowserApp/ReaderMode.tsx ==="
cat desktop/src/apps/BrowserApp/ReaderMode.tsx

echo ""
echo "=== desktop/src/apps/BrowserApp/TabRenderer.tsx ==="
cat desktop/src/apps/BrowserApp/TabRenderer.tsx

echo ""
echo "=== desktop/src/apps/BrowserApp/live-exclusion.ts ==="
cat desktop/src/apps/BrowserApp/live-exclusion.ts

echo ""
echo "=== desktop/src/apps/BrowserApp/ProfileSwitcher.tsx ==="
cat desktop/src/apps/BrowserApp/ProfileSwitcher.tsx

echo ""
echo "=== desktop/src/apps/BrowserApp/ProfileManager.tsx ==="
cat desktop/src/apps/BrowserApp/ProfileManager.tsx

echo ""
echo "=== desktop/src/apps/BrowserApp/Chrome.tsx ==="
cat desktop/src/apps/BrowserApp/Chrome.tsx

echo ""
echo "=== desktop/src/stores/browser-store.ts ==="
cat desktop/src/stores/browser-store.ts

echo ""
echo "=== desktop/src/stores/browser-settings-store.ts ==="
cat desktop/src/stores/browser-settings-store.ts

echo ""
echo "=== desktop/src/lib/browser-profile-api.ts ==="
cat desktop/src/lib/browser-profile-api.ts

echo ""
echo "=== desktop/src/lib/browser-extract-api.ts ==="
cat desktop/src/lib/browser-extract-api.ts

Length of output: 65108


@jaylfc — full re-review of commit 74c3f85 below.


✅ Atomic SQL fixes — confirmed correct

claim_profile_init (store.py)

INSERT OR IGNORE INTO profile_init (user_id, initialized_at) VALUES (?, ?)

The PRIMARY KEY (user_id) constraint on profile_init makes this genuinely atomic. Exactly one concurrent caller gets rowcount == 1; all others see 0 and skip seeding. ensure_default_profiles cleanly delegates to this single claim call. ✅

delete_profile (store.py)

DELETE FROM profiles
WHERE user_id = ? AND profile_id = ?
  AND (SELECT COUNT(*) FROM profiles WHERE user_id = ?) > 1

The COUNT subquery and the DELETE execute as a single statement. With aiosqlite's single-connection serialization, two concurrent DELETE calls are queued; after the first removes the penultimate profile, the second evaluates COUNT = 1, the > 1 predicate is false, and it returns rowcount = 0LastProfileError. ✅

The delete_profile_cascade post-failure list re-read that distinguishes "last profile" vs "not found" is correct: if the profile is still in the list after a failed delete → LastProfileError; if it's absent → False. Safe under single-connection serialization. ✅


🐛 Remaining issues

1. create_profile — silent concurrent slug collision (Medium)

add_profile uses INSERT OR IGNORE, but create_profile never checks whether the insert actually fired. Two concurrent POSTs for identical names will:

  1. Both call list_profiles → both see no "my-profile" slug
  2. Both call add_profile("my-profile", …) → one is silently ignored
  3. Both return the same {"profile_id": "my-profile", …} dict claiming success

Only one row lands in the DB; the second caller gets a ghost profile_id.

Suggested fix in store.py — surface the conflict:

async def add_profile(self, *, user_id, profile_id, name, color, created_at) -> bool:
    ...
    cursor = await self._db.execute(
        "INSERT OR IGNORE INTO profiles (...) VALUES (?, ?, ?, ?, ?)",
        (user_id, profile_id, name, color, created_at),
    )
    await self._db.commit()
    return cursor.rowcount > 0  # False = slug already taken

And in profile.py create_profile, retry the slug on False:

inserted = await store.add_profile(...)
if not inserted:
    # Rare race: another request took this slug concurrently — append suffix and retry
    candidate = f"{base_slug}-{suffix}"
    suffix += 1
    # … retry loop

2. extract.pyprofile_id parameter is unused (Low)

async def extract_endpoint(
    profile_id: str,   # ← accepted but never referenced below
    url: str,
    ...

The endpoint fetches url raw with httpx (no cookie injection, no profile state). Either remove the parameter from the route and the client browser-extract-api.ts, or add a # noqa comment explaining why it's present but intentionally unused (e.g., reserved for future per-profile caching). Currently it silently accepts and discards it.


3. ProfileManager.handleDelete — undifferentiated error message (Low)

deleteProfile() in browser-profile-api.ts returns boolean — it can't distinguish a 400 (last profile) from a 404 (not found). The error message is hardcoded:

setError("Delete failed (cannot delete last profile)");

This would appear even for a stale 404. Consider either exposing the status code or using a generic message like "Delete failed".


4. live-exclusion.tsvideoWidth check (Low)

if ((media as HTMLVideoElement).videoWidth !== undefined) {
  return "video";
}
return "audio";

videoWidth is a property of HTMLVideoElement only; accessing it on HTMLAudioElement yields undefined in all current browsers — so the logic works. But the cast as HTMLVideoElement could mask future regressions. A safer check:

if (media instanceof HTMLVideoElement) {
  return "video";
}
return "audio";

5. Chrome.tsxprofileColor hardcodes only two profiles (Low)

function profileColor(profileId: string): string {
  switch (profileId) { case "personal":  case "work":  default: return "#8b92a3"; }
}

Custom profiles (created via ProfileSwitcher/ProfileManager) always render the neutral grey #8b92a3 on the chip. The ProfileSwitcher already loads real colors from the backend — threading that color up to Chrome (or reading from a local profiles state) would make the chip consistent.


Summary

Area Status
Default-profile seeding race (claim_profile_init) ✅ Fixed
Last-profile delete race (delete_profile COUNT subquery) ✅ Fixed
create_profile concurrent slug collision 🔴 Still present
profile_id unused in extract_endpoint 🟡 Minor
ProfileManager undifferentiated delete error 🟡 Minor
live-exclusion instanceof vs cast 🟡 Minor
Chrome hardcoded profile colours 🟡 Minor

The two primary race conditions you flagged are solidly fixed. The create_profile slug collision is the most actionable remaining item.

@jaylfc
Copy link
Copy Markdown
Owner Author

jaylfc commented May 4, 2026

@coderabbitai full review — please re-review commit cf22778, which addresses your previous round (slug-collision retry in create_profile, chip color fetch in Chrome.tsx, instanceof scoping in live-exclusion.ts, generic delete error message, and reserved-but-documented profile_id in extract_endpoint).

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== tinyagentos/routes/desktop_browser/profile.py ==="
cat tinyagentos/routes/desktop_browser/profile.py

echo ""
echo "=== tinyagentos/routes/desktop_browser/store.py ==="
cat tinyagentos/routes/desktop_browser/store.py

echo ""
echo "=== desktop/src/apps/BrowserApp/Chrome.tsx ==="
cat desktop/src/apps/BrowserApp/Chrome.tsx

echo ""
echo "=== desktop/src/apps/BrowserApp/live-exclusion.ts ==="
cat desktop/src/apps/BrowserApp/live-exclusion.ts

echo ""
echo "=== desktop/src/apps/BrowserApp/ProfileManager.tsx ==="
cat desktop/src/apps/BrowserApp/ProfileManager.tsx

echo ""
echo "=== tinyagentos/routes/desktop_browser/extract.py ==="
cat tinyagentos/routes/desktop_browser/extract.py

Length of output: 49948


@jaylfc — re-review of commit cf22778 complete. All five items from the previous round are confirmed fixed.


✅ All previous issues resolved

1. create_profile — slug-collision retry ✅

store.add_profile now returns bool (cursor.rowcount > 0). create_profile wraps the candidate-selection + insert in a for attempt in range(100) loop: on inserted == False it loops and re-reads list_profiles to pick the next available slug. The RuntimeError at the end of the loop is a safe escape hatch. Under aiosqlite's single-connection serialization, the loop converges in at most two iterations in any real-world race.

2. Chrome.tsx — chip color from backend ✅

The hardcoded profileColor(profileId) switch is gone. Chrome now loads the full Profile[] list with listProfiles() on mount and re-fetches when currentProfileId changes (so a rename in ProfileManager is reflected immediately). The chip computes activeProfile?.color ?? "#8b92a3" and activeProfile?.name ?? win.profileId inline. Custom profiles now render their correct color.

3. live-exclusion.tsinstanceof scoping ✅

const iframeWin = iframe.contentWindow as (Window & { HTMLVideoElement?: typeof HTMLVideoElement }) | null;
const VideoElement = iframeWin?.HTMLVideoElement ?? HTMLVideoElement;
// …
if (media instanceof VideoElement) { return "video"; }

Using the iframe's own HTMLVideoElement constructor is the correct cross-realm instanceof pattern for same-origin iframes — it also works in JSDOM test environments where the fallback to the parent HTMLVideoElement is needed.

4. ProfileManager — generic delete error ✅

handleDelete now sets setError("Delete failed") — no longer incorrectly attributing a 404 failure to a last-profile constraint. Network errors surface separately as "Network error — could not delete profile".

5. extract.pyprofile_id documented ✅

The parameter carries a comment (# reserved for future per-profile caching; not yet used), the function docstring explains the intent, and _ = profile_id suppresses the linter warning cleanly.


Summary

Previous issue Status
create_profile concurrent slug collision ✅ Fixed — retry loop + add_profile returns bool
Chrome hardcoded profile colours ✅ Fixed — live fetch from backend
live-exclusion instanceof vs cast ✅ Fixed — cross-realm instanceof pattern
ProfileManager undifferentiated delete error ✅ Fixed — generic message
profile_id unused in extract_endpoint ✅ Fixed — reserved + documented

No new issues found across the reviewed files. The PR is in good shape. 🎉

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (6)
tinyagentos/routes/desktop_browser/profile.py (2)

85-87: 💤 Low value

Consider moving imports to the top of the file.

Placing import re and the BrowserCookieStore import mid-file after function definitions is non-standard. If this is intentional for PR organization, consider consolidating imports at the top in a follow-up.

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

In `@tinyagentos/routes/desktop_browser/profile.py` around lines 85 - 87, The
imports "re" and "BrowserCookieStore" are placed mid-file; move them to the
module import block at the top alongside other imports to follow standard style.
Locate the occurrences of "import re" and "from
tinyagentos.routes.desktop_browser.store import BrowserCookieStore" and relocate
them to the top of tinyagentos/routes/desktop_browser/profile.py, ensuring they
are grouped with existing imports and that any functions or classes (e.g., the
profile route handlers) still reference these symbols without changing behavior.

117-149: 💤 Low value

Slug-collision retry logic is correct.

Using INSERT OR IGNORE as the authoritative guard with application-level retry properly handles the race between list_profiles and add_profile. The 100-attempt cap is a reasonable safety bound.

Minor: rename unused loop variable attempt_attempt to signal intent (per Ruff B007).

♻️ Optional: rename unused loop variable
-    for attempt in range(max_attempts):
+    for _attempt in range(max_attempts):
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/routes/desktop_browser/profile.py` around lines 117 - 149, The
unused loop variable `attempt` in the retry loop should be renamed to `_attempt`
to signal it's intentionally unused and satisfy lint rule Ruff B007; locate the
loop `for attempt in range(max_attempts):` and change the variable name to
`_attempt` (no other logic changes).
desktop/src/apps/BrowserApp/ProfileManager.test.tsx (1)

290-382: ⚡ Quick win

Remove the unused first render to clean up test structure.

The test renders ProfileManager twice: once at lines 313-318 with a mock that's immediately overwritten, and again at lines 357-362 with the correct mock sequence. The first render is never unmounted and creates duplicate DOM elements, requiring getAllBy* with index access patterns ([0], [deleteBtns.length - 1]).

Remove lines 296-325 (the first mock setup, first render, and explanatory comments) since the second render with the proper mock sequence is the actual test.

♻️ Proposed cleanup
   it("Test A: recovers orphan windows in other windows after delete", async () => {
     // Set up two windows: window-a on personal, window-b on work
     useBrowserStore.getState().createWindow("window-a", "personal");
     useBrowserStore.getState().createWindow("window-b", "work");
 
-    // Mock: DELETE succeeds, then GET list returns only personal (work was removed)
-    global.fetch = vi.fn().mockImplementation((url: string, opts: any) => {
-      if (opts?.method === "DELETE") {
-        return Promise.resolve({ ok: true, status: 204 });
-      }
-      // GET /profiles — return only personal after delete
-      return Promise.resolve({
-        ok: true,
-        status: 200,
-        json: async () => ({
-          profiles: [
-            { profile_id: "personal", name: "Personal", color: "#6c8df0", created_at: 0 },
-          ],
-        }),
-      });
-    });
-
-    // Render ProfileManager as if the user in window-a opened it (active = personal)
-    render(
-      <ProfileManager
-        activeProfileId="personal"
-        onClose={() => {}}
-      />,
-    );
-
-    // Wait for the initial profile list (which initially has personal + work)
-    // But we've already set up fetch to return only personal, so wait for Work to appear
-    // via the initial load mock — we need to override the initial load too.
-    // Let's wait for the list to settle: the first GET returns personal only (no work).
-    // Because the fetch is mocked to return personal-only for ALL GETs, the list
-    // will show only Personal. We won't see Work in the UI.
-    // To properly test the flow, we need Work in the initial list.
-    // Re-mock: first GET returns both, DELETE succeeds, second GET returns only personal.
     global.fetch = vi.fn()
       .mockImplementationOnce(() =>
         Promise.resolve({
           ...
         }),
       )
       ...;
 
-    // Re-render with correct mock sequence
-    const { unmount } = render(
+    render(
       <ProfileManager
         activeProfileId="personal"
         onClose={() => {}}
       />,
     );
 
-    await waitFor(() => screen.getAllByText("Work")[0]);
+    await waitFor(() => screen.getByText("Work"));
 
     // Click delete on Work
-    const deleteBtns = screen.getAllByLabelText(/delete profile work/i);
-    fireEvent.click(deleteBtns[deleteBtns.length - 1]);
+    const deleteBtn = screen.getByLabelText(/delete profile work/i);
+    fireEvent.click(deleteBtn);
 
     // Confirm the deletion
-    await waitFor(() => screen.getAllByText(/this also clears all saved cookies/i)[0]);
-    const confirmBtns = screen.getAllByLabelText(/confirm delete/i);
-    fireEvent.click(confirmBtns[confirmBtns.length - 1]);
+    await waitFor(() => screen.getByText(/this also clears all saved cookies/i));
+    fireEvent.click(screen.getByLabelText(/confirm delete/i));
 
     // After delete completes, window-b should have been switched to personal
     await waitFor(() => {
       const windowB = useBrowserStore.getState().windows["window-b"];
       expect(windowB?.profileId).toBe("personal");
     });
-
-    unmount();
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/BrowserApp/ProfileManager.test.tsx` around lines 290 - 382,
Test renders ProfileManager twice causing duplicate DOM and reliance on indexed
queries; remove the first (unused) render and its preceding mock so the test
only sets up the intended mock sequence and single render. Specifically, delete
the initial global.fetch mock block and the first render(...) call that uses
ProfileManager (the render before the re-mock and re-render), leaving only the
mockImplementationOnce chain, the subsequent render of ProfileManager, and the
assertions that follow; ensure useBrowserStore.createWindow calls remain to set
up windows "window-a" and "window-b".
desktop/src/apps/BrowserApp/Chrome.test.tsx (1)

91-96: 💤 Low value

Prefer screen queries over raw document.querySelector for better failure diagnostics.

When dot is null, the assertion produces "expected undefined to be 'rgb(...)'" with no indication the element was absent. Using screen.getByLabelText to locate the chip first surfaces a clear "unable to find element" message instead.

♻️ Suggested refactor
-    await waitFor(() => {
-      const dot = document.querySelector(
-        "[aria-label='Profile: personal'] span[aria-hidden='true']",
-      ) as HTMLElement | null;
-      expect(dot?.style.backgroundColor).toBe("rgb(171, 205, 239)"); // `#abcdef` in rgb
-    });
+    await waitFor(() => {
+      const chip = screen.getByLabelText(/profile: personal/i);
+      const dot = chip.querySelector("span[aria-hidden='true']") as HTMLElement | null;
+      expect(dot).not.toBeNull();
+      expect(dot!.style.backgroundColor).toBe("rgb(171, 205, 239)"); // `#abcdef` in rgb
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/BrowserApp/Chrome.test.tsx` around lines 91 - 96, Replace
the raw document.querySelector call in the test's waitFor block with Testing
Library's screen query to improve diagnostics: use
screen.getByLabelText("Profile: personal") to retrieve the chip element (this
will throw a helpful error if missing), then locate the inner span (e.g., via
chip.querySelector("span[aria-hidden='true']") or within(chip).getByRole if
appropriate) and assert its style.backgroundColor equals "rgb(171, 205, 239)";
update the test that currently calls waitFor and document.querySelector to use
screen.getByLabelText and the chip variable instead (keep the waitFor around the
assertion if asynchronous).
tinyagentos/routes/desktop_browser/extract.py (2)

63-65: 💤 Low value

Silent except: pass hides lxml parse failures (Ruff S110).

When lxml_html.fromstring raises, the error is silently eaten and text stays empty. word_count will be 0 and the caller gets back a suspiciously empty result with no indication of what went wrong. A single _logger.debug call preserves the "never raises" contract while aiding diagnosis.

♻️ Proposed fix
     except Exception:
-        # Empty / malformed summary — fall through with empty text
-        pass
+        _logger.debug("lxml text_content failed for extracted summary; returning empty text")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/routes/desktop_browser/extract.py` around lines 63 - 65, The bare
except in the HTML parsing block swallows lxml_html.fromstring errors leaving
text empty and word_count 0; change the except in the extract logic to capture
the exception (e.g., except Exception as e) and emit a debug log via
_logger.debug including the exception message/stack (and the URL or snippet
context if available) so failures are recorded while still falling through to
the empty text behavior; update references in the same block that compute
text/word_count to remain unchanged.

53-55: Pass url to Document constructor to absolutize relative links in extracted summary HTML.

The extract_readable function receives url as a parameter but doesn't forward it to Document. The readability-lxml library's Document class accepts a url keyword argument and uses it internally to call make_links_absolute, converting href="/path" and src="./img.jpg" into full URLs. Without this, relative links and images in the summary HTML will be broken.

♻️ One-line fix
-        doc = Document(decoded)
+        doc = Document(decoded, url=url)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/routes/desktop_browser/extract.py` around lines 53 - 55, The
Document is constructed without the source URL so relative links in summary_html
stay relative; change the call that creates the Document (the Document(...)
invocation inside extract_readable) to pass the received url argument (e.g.,
Document(decoded, url=url)) so that Document.make_links_absolute runs and
doc.summary(html_partial=True) returns absolute href/src values; keep the rest
of the logic (title via doc.short_title() or doc.title()) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tinyagentos/routes/desktop_browser/extract.py`:
- Around line 107-111: The code currently creates an httpx.AsyncClient with
follow_redirects=True which allows silent 3xx hops to bypass SSRF checks; change
the client to use follow_redirects=False in the httpx.AsyncClient call (where
response = await http.get(url) is invoked) and either reject 3xx responses or
implement manual hop handling: on 3xx inspect the Location header, call
validate_url_or_raise(Location) for each redirect target, then perform a new GET
to the validated Location. Ensure validate_url_or_raise is used before any
secondary fetch so every redirect hop is re-validated.

---

Nitpick comments:
In `@desktop/src/apps/BrowserApp/Chrome.test.tsx`:
- Around line 91-96: Replace the raw document.querySelector call in the test's
waitFor block with Testing Library's screen query to improve diagnostics: use
screen.getByLabelText("Profile: personal") to retrieve the chip element (this
will throw a helpful error if missing), then locate the inner span (e.g., via
chip.querySelector("span[aria-hidden='true']") or within(chip).getByRole if
appropriate) and assert its style.backgroundColor equals "rgb(171, 205, 239)";
update the test that currently calls waitFor and document.querySelector to use
screen.getByLabelText and the chip variable instead (keep the waitFor around the
assertion if asynchronous).

In `@desktop/src/apps/BrowserApp/ProfileManager.test.tsx`:
- Around line 290-382: Test renders ProfileManager twice causing duplicate DOM
and reliance on indexed queries; remove the first (unused) render and its
preceding mock so the test only sets up the intended mock sequence and single
render. Specifically, delete the initial global.fetch mock block and the first
render(...) call that uses ProfileManager (the render before the re-mock and
re-render), leaving only the mockImplementationOnce chain, the subsequent render
of ProfileManager, and the assertions that follow; ensure
useBrowserStore.createWindow calls remain to set up windows "window-a" and
"window-b".

In `@tinyagentos/routes/desktop_browser/extract.py`:
- Around line 63-65: The bare except in the HTML parsing block swallows
lxml_html.fromstring errors leaving text empty and word_count 0; change the
except in the extract logic to capture the exception (e.g., except Exception as
e) and emit a debug log via _logger.debug including the exception message/stack
(and the URL or snippet context if available) so failures are recorded while
still falling through to the empty text behavior; update references in the same
block that compute text/word_count to remain unchanged.
- Around line 53-55: The Document is constructed without the source URL so
relative links in summary_html stay relative; change the call that creates the
Document (the Document(...) invocation inside extract_readable) to pass the
received url argument (e.g., Document(decoded, url=url)) so that
Document.make_links_absolute runs and doc.summary(html_partial=True) returns
absolute href/src values; keep the rest of the logic (title via
doc.short_title() or doc.title()) unchanged.

In `@tinyagentos/routes/desktop_browser/profile.py`:
- Around line 85-87: The imports "re" and "BrowserCookieStore" are placed
mid-file; move them to the module import block at the top alongside other
imports to follow standard style. Locate the occurrences of "import re" and
"from tinyagentos.routes.desktop_browser.store import BrowserCookieStore" and
relocate them to the top of tinyagentos/routes/desktop_browser/profile.py,
ensuring they are grouped with existing imports and that any functions or
classes (e.g., the profile route handlers) still reference these symbols without
changing behavior.
- Around line 117-149: The unused loop variable `attempt` in the retry loop
should be renamed to `_attempt` to signal it's intentionally unused and satisfy
lint rule Ruff B007; locate the loop `for attempt in range(max_attempts):` and
change the variable name to `_attempt` (no other logic changes).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7bb36690-e02a-4f49-a6ef-378e7eda0bfd

📥 Commits

Reviewing files that changed from the base of the PR and between f5caaca and cf22778.

📒 Files selected for processing (11)
  • desktop/src/apps/BrowserApp/Chrome.test.tsx
  • desktop/src/apps/BrowserApp/Chrome.tsx
  • desktop/src/apps/BrowserApp/ProfileManager.test.tsx
  • desktop/src/apps/BrowserApp/ProfileManager.tsx
  • desktop/src/apps/BrowserApp/live-exclusion.ts
  • desktop/src/stores/browser-settings-store.test.ts
  • tests/routes/desktop_browser/test_profile_routes.py
  • tinyagentos/routes/desktop_browser/extract.py
  • tinyagentos/routes/desktop_browser/profile.py
  • tinyagentos/routes/desktop_browser/profile_routes.py
  • tinyagentos/routes/desktop_browser/store.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • desktop/src/apps/BrowserApp/live-exclusion.ts
  • desktop/src/stores/browser-settings-store.test.ts
  • desktop/src/apps/BrowserApp/Chrome.tsx
  • desktop/src/apps/BrowserApp/ProfileManager.tsx
  • tinyagentos/routes/desktop_browser/profile_routes.py
  • tests/routes/desktop_browser/test_profile_routes.py

Comment thread tinyagentos/routes/desktop_browser/extract.py Outdated
…-review

- extract.py: walk redirects manually with follow_redirects=False; re-run
  validate_url_or_raise on each hop Location header (mirrors proxy.py pattern);
  return 403 on SSRF block, 502 on chain > 5 hops
- extract.py: pass url= to Document() so readability-lxml absolutizes relative links
- extract.py: replace silent except: pass with _logger.debug for visibility
- profile.py: move mid-file `import re` and BrowserCookieStore import to top
- profile.py: rename loop variable attempt -> _attempt (Ruff B007)
- ProfileManager.test.tsx: remove orphan double-render in Test A; switch
  getAllBy* with index to getBy* now that there is a single tree
- Chrome.test.tsx: replace document.querySelector with screen.getByLabelText
  for better failure diagnostics in the chip-color test
- tests/test_extract.py: add 4 SSRF redirect regression tests covering RFC1918,
  AWS IMDS, chain-too-long, and normal redirect success cases
@jaylfc
Copy link
Copy Markdown
Owner Author

jaylfc commented May 4, 2026

@coderabbitai full review — please re-review commit f3117c7. Critical fix: extract.py SSRF redirect bypass closed by switching follow_redirects=False + per-hop validate_url_or_raise loop (mirroring proxy.py). Plus Document(url=url) for absolute links, _logger.debug for lxml fallback, profile.py import cleanup, _attempt rename, ProfileManager.test.tsx double-render cleanup, Chrome.test.tsx screen.getByLabelText.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

Tip

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== tinyagentos/routes/desktop_browser/extract.py ==="
cat tinyagentos/routes/desktop_browser/extract.py

echo ""
echo "=== tinyagentos/routes/desktop_browser/profile.py ==="
cat tinyagentos/routes/desktop_browser/profile.py

echo ""
echo "=== desktop/src/apps/BrowserApp/ProfileManager.test.tsx ==="
cat desktop/src/apps/BrowserApp/ProfileManager.test.tsx

echo ""
echo "=== desktop/src/apps/BrowserApp/Chrome.test.tsx ==="
cat desktop/src/apps/BrowserApp/Chrome.test.tsx

Length of output: 31471


@jaylfc — re-review of commit f3117c7 complete. All seven stated changes are confirmed correct.


✅ All stated fixes verified

1. SSRF redirect bypass — follow_redirects=False + per-hop loop ✅

The implementation mirrors proxy.py exactly:

async with httpx.AsyncClient(follow_redirects=False, timeout=_FETCH_TIMEOUT) as http:
    fetch_url = url
    for _hop in range(_MAX_HOPS):
        response = await http.get(fetch_url)
        if response.is_redirect:
            location = response.headers.get("location")
            fetch_url = urljoin(fetch_url, location)
            validate_url_or_raise(fetch_url)   # ← SSRF re-check on every hop
            continue
        break
    else:
        return JSONResponse({"error": "redirect chain too long"}, status_code=502)

urljoin correctly resolves relative Location values; the for/else fires the 502 if all 5 hops are redirects; the assert response is not None guard after the loop is correct. ✅

2. Document(url=url)

doc = Document(decoded, url=url) is present — Readability can now absolutize href/src via make_links_absolute. ✅

3. _logger.debug for lxml fallback ✅

Downgraded from a noisier level to _logger.debug("lxml text_content failed…") — correct since a parse failure here is non-critical and doesn't need operator attention. ✅

4. profile.py import cleanup ✅

Only re, time, BrowserCookieStore, and BrowserStore are imported — all are used. No dead imports remain. ✅

5. _attempt rename ✅

for _attempt in range(max_attempts): follows the Python convention of prefixing intentionally unused loop variables with _. ✅

6. ProfileManager.test.tsx double-render cleanup ✅

Each test issues exactly one render(...) call. No beforeEach-level render that would compound with in-test renders. ✅

7. Chrome.test.tsxgetByLabelText

const chip = screen.getByLabelText(/profile: personal/i);

Accessibility-based query is more robust than a text/class selector and will catch aria-label regressions too. The waitFor wrapping the color-dot assertion correctly accounts for the async listProfiles call. ✅


🔍 Two new minor findings

1. extract.pyurlparse imported but unused (Low)

from urllib.parse import urljoin, urlparse, urlsplit  # urlparse is never referenced

urlparse is not called anywhere in the file; only urljoin and urlsplit are used. Can be removed.

# fix
from urllib.parse import urljoin, urlsplit

2. extract.pyurl vs fetch_url passed to extract_readable (Low)

At the end of the redirect loop the call is:

return extract_readable(response.content, url)   # url = original, not fetch_url

extract_readable passes this as Document(decoded, url=url), which is what Readability uses to absolutize relative href/src inside the article HTML. If the server redirected (e.g. http://…https://…/article/), relative links inside the final page's HTML will be resolved against the original URL, not the final destination. For most same-origin redirects this is harmless, but for cross-path redirects it can produce broken image/link URLs in Reader mode.

Suggested fix:

return extract_readable(response.content, fetch_url)  # use final URL after redirects

Summary

Item Status
SSRF redirect bypass (follow_redirects=False + per-hop validate) ✅ Fixed
Document(url=url) for absolute links ✅ Fixed
_logger.debug for lxml fallback ✅ Fixed
profile.py import cleanup ✅ Fixed
_attempt rename ✅ Fixed
ProfileManager.test.tsx double-render cleanup ✅ Fixed
Chrome.test.tsx screen.getByLabelText ✅ Fixed
urlparse dead import in extract.py 🟡 New / Minor
url vs fetch_url in extract_readable call 🟡 New / Minor

The critical SSRF fix is solid. Only two low-severity items remain; the fetch_url one is the more actionable of the two.

@jaylfc jaylfc merged commit 4bd5944 into master May 4, 2026
8 checks passed
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