From 4bfb510348910a83da9229c9e63a512adc908e00 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 5 May 2026 17:33:41 +0100 Subject: [PATCH 1/3] feat(browser): BookmarksBar component (PR 9 follow-up) --- .../src/apps/BrowserApp/BookmarksBar.test.tsx | 119 ++++++++++++ desktop/src/apps/BrowserApp/BookmarksBar.tsx | 176 ++++++++++++++++++ desktop/src/apps/BrowserApp/BrowserApp.tsx | 2 + 3 files changed, 297 insertions(+) create mode 100644 desktop/src/apps/BrowserApp/BookmarksBar.test.tsx create mode 100644 desktop/src/apps/BrowserApp/BookmarksBar.tsx diff --git a/desktop/src/apps/BrowserApp/BookmarksBar.test.tsx b/desktop/src/apps/BrowserApp/BookmarksBar.test.tsx new file mode 100644 index 00000000..8b6aa9e6 --- /dev/null +++ b/desktop/src/apps/BrowserApp/BookmarksBar.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { BookmarksBar } from "./BookmarksBar"; +import * as bookmarksApi from "@/lib/browser-bookmarks-api"; +import { useBrowserStore } from "@/stores/browser-store"; + +vi.mock("@/lib/browser-bookmarks-api"); + +const WINDOW_ID = "win-bbar"; +const PROFILE_ID = "personal"; + +function makeBookmark(overrides: Partial = {}): bookmarksApi.Bookmark { + return { + bookmark_id: "bm-1", + url: "https://example.com", + title: "Example Site", + created_at: Date.now(), + ...overrides, + }; +} + +beforeEach(() => { + vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([]); + vi.mocked(bookmarksApi.removeBookmark).mockResolvedValue(true); + useBrowserStore.setState({ windows: {} }); + useBrowserStore.getState().createWindow(WINDOW_ID, PROFILE_ID); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("BookmarksBar", () => { + it("renders null (nothing) when bookmark list is empty", async () => { + vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([]); + const { container } = render( + , + ); + // Wait for the async load to settle + await act(async () => {}); + expect(container.firstChild).toBeNull(); + }); + + it("renders one chip per bookmark", async () => { + vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([ + makeBookmark({ bookmark_id: "bm-1", title: "Alpha", url: "https://alpha.com" }), + makeBookmark({ bookmark_id: "bm-2", title: "Beta", url: "https://beta.com" }), + ]); + + render(); + await waitFor(() => screen.getByText("Alpha")); + expect(screen.getByText("Beta")).toBeTruthy(); + }); + + it("click on chip calls navigateTab with bookmark URL", async () => { + vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([ + makeBookmark({ bookmark_id: "bm-1", title: "Docs", url: "https://docs.example.com" }), + ]); + const navigateSpy = vi.spyOn(useBrowserStore.getState(), "navigateTab"); + + render(); + await waitFor(() => screen.getByText("Docs")); + + fireEvent.click(screen.getByRole("button", { name: /Go to Docs/i })); + expect(navigateSpy).toHaveBeenCalledWith( + WINDOW_ID, + expect.any(String), + "https://docs.example.com", + ); + }); + + it("right-click opens context menu, Remove calls removeBookmark with bookmark id", async () => { + vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([ + makeBookmark({ bookmark_id: "bm-42", title: "My Page", url: "https://my.example.com" }), + ]); + + render(); + await waitFor(() => screen.getByText("My Page")); + + const chip = screen.getByRole("button", { name: /Go to My Page/i }); + fireEvent.contextMenu(chip); + + const removeBtn = await waitFor(() => screen.getByRole("menuitem", { name: /Remove bookmark/i })); + await act(async () => { + fireEvent.click(removeBtn); + }); + + expect(bookmarksApi.removeBookmark).toHaveBeenCalledWith(PROFILE_ID, "bm-42"); + }); + + it("after successful remove the chip disappears", async () => { + vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([ + makeBookmark({ bookmark_id: "bm-1", title: "ToRemove", url: "https://remove.me" }), + ]); + vi.mocked(bookmarksApi.removeBookmark).mockResolvedValue(true); + + render(); + await waitFor(() => screen.getByText("ToRemove")); + + const chip = screen.getByRole("button", { name: /Go to ToRemove/i }); + fireEvent.contextMenu(chip); + const removeBtn = await waitFor(() => screen.getByRole("menuitem", { name: /Remove bookmark/i })); + await act(async () => { + fireEvent.click(removeBtn); + }); + + await waitFor(() => expect(screen.queryByText("ToRemove")).toBeNull()); + }); + + it("truncates long titles to ~20 chars", async () => { + const longTitle = "A Very Long Title That Should Be Truncated"; + vi.mocked(bookmarksApi.listBookmarks).mockResolvedValue([ + makeBookmark({ bookmark_id: "bm-1", title: longTitle, url: "https://long.example.com" }), + ]); + + render(); + await waitFor(() => screen.getByText("A Very Long Title Th…")); + }); +}); diff --git a/desktop/src/apps/BrowserApp/BookmarksBar.tsx b/desktop/src/apps/BrowserApp/BookmarksBar.tsx new file mode 100644 index 00000000..63dd3252 --- /dev/null +++ b/desktop/src/apps/BrowserApp/BookmarksBar.tsx @@ -0,0 +1,176 @@ +/** + * BrowserApp v2 — BookmarksBar. + * + * Horizontal row of bookmark chips shown below the tab strip when the + * active profile has at least one bookmark. Auto-hides when empty. + * + * Each chip: + * - Favicon (Google S2 service, 32px) + * - Title truncated to ~20 characters + * - Click → navigates active tab + * - Right-click → context menu with "Remove bookmark" + */ +import { useEffect, useRef, useState } from "react"; +import { listBookmarks, removeBookmark, type Bookmark } from "@/lib/browser-bookmarks-api"; +import { useBrowserStore } from "@/stores/browser-store"; + +interface BookmarksBarProps { + windowId: string; + profileId: string; +} + +function faviconUrl(url: string): string { + try { + const { hostname } = new URL(url); + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=32`; + } catch { + return ""; + } +} + +function truncate(text: string, max = 20): string { + if (text.length <= max) return text; + return text.slice(0, max) + "…"; +} + +interface ContextMenuState { + x: number; + y: number; + bookmarkId: string; +} + +export function BookmarksBar({ windowId, profileId }: BookmarksBarProps) { + const navigateTab = useBrowserStore((s) => s.navigateTab); + const win = useBrowserStore((s) => s.windows[windowId]); + + const [bookmarks, setBookmarks] = useState([]); + const [contextMenu, setContextMenu] = useState(null); + const menuRef = useRef(null); + const loadSeqRef = useRef(0); + + async function load() { + const seq = ++loadSeqRef.current; + const list = await listBookmarks(profileId); + if (seq !== loadSeqRef.current) return; + setBookmarks(list); + } + + useEffect(() => { + load(); + }, [profileId]); + + // React to bookmark-changed events fired by other components (AddressBar star, + // etc.) so the bar stays in sync. Skip events we dispatched ourselves. + useEffect(() => { + const handler = (e: Event) => { + const ce = e as CustomEvent<{ profileId: string; url: string; bookmarkId: string | null; source?: string }>; + if (ce.detail.profileId !== profileId) return; + if (ce.detail.source === "bookmarks-bar") return; + load(); + }; + window.addEventListener("taos-browser:bookmark-changed", handler); + return () => window.removeEventListener("taos-browser:bookmark-changed", handler); + }, [profileId]); + + // Dismiss context menu on outside click + useEffect(() => { + if (!contextMenu) return; + const handler = (e: MouseEvent) => { + if (!menuRef.current?.contains(e.target as Node)) { + setContextMenu(null); + } + }; + window.addEventListener("mousedown", handler); + return () => window.removeEventListener("mousedown", handler); + }, [contextMenu]); + + if (bookmarks.length === 0) return null; + + const activeTabId = win?.activeTabId; + + function handleChipClick(url: string) { + if (!activeTabId) return; + navigateTab(windowId, activeTabId, url); + } + + function handleChipContextMenu(e: React.MouseEvent, bookmarkId: string) { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, bookmarkId }); + } + + async function handleRemove(bookmarkId: string) { + setContextMenu(null); + const removed = bookmarks.find((b) => b.bookmark_id === bookmarkId); + const ok = await removeBookmark(profileId, bookmarkId); + if (ok) { + // Update local state immediately — don't wait for a re-fetch + setBookmarks((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId)); + // Notify other components (AddressBar star icon, etc.) but mark the + // event as originating here so our own listener doesn't trigger a reload + window.dispatchEvent( + new CustomEvent("taos-browser:bookmark-changed", { + detail: { + profileId, + url: removed?.url ?? "", + bookmarkId: null, + source: "bookmarks-bar", + }, + }), + ); + } + } + + return ( + <> +
+ {bookmarks.map((bm) => ( + + ))} +
+ + {contextMenu && ( +
+ +
+ )} + + ); +} diff --git a/desktop/src/apps/BrowserApp/BrowserApp.tsx b/desktop/src/apps/BrowserApp/BrowserApp.tsx index f829a920..1a5dcf43 100644 --- a/desktop/src/apps/BrowserApp/BrowserApp.tsx +++ b/desktop/src/apps/BrowserApp/BrowserApp.tsx @@ -26,6 +26,7 @@ import { FindInPage } from "./FindInPage"; import { TabOverview } from "./TabOverview"; import { WindowChooser } from "./WindowChooser"; import { CapabilityPromptModal } from "./CapabilityPromptModal"; +import { BookmarksBar } from "./BookmarksBar"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { Layers, ListChecks } from "lucide-react"; import { bootstrapPushSubscription } from "../../lib/browser-push-bootstrap"; @@ -145,6 +146,7 @@ export function BrowserApp({ windowId }: BrowserAppProps) {
+ {findOpen && ( From 1e0198712a1059beff3185021176982fe2a87b74 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 5 May 2026 17:33:44 +0100 Subject: [PATCH 2/3] feat(browser): site permissions settings panel (PR 9 follow-up) --- desktop/src/apps/BrowserApp/SettingsPanel.tsx | 31 +++- .../BrowserApp/SitePermissionsPanel.test.tsx | 135 ++++++++++++++++ .../apps/BrowserApp/SitePermissionsPanel.tsx | 151 ++++++++++++++++++ .../src/lib/browser-site-permissions-api.ts | 53 ++++++ 4 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 desktop/src/apps/BrowserApp/SitePermissionsPanel.test.tsx create mode 100644 desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx create mode 100644 desktop/src/lib/browser-site-permissions-api.ts diff --git a/desktop/src/apps/BrowserApp/SettingsPanel.tsx b/desktop/src/apps/BrowserApp/SettingsPanel.tsx index bec2193a..97d48de9 100644 --- a/desktop/src/apps/BrowserApp/SettingsPanel.tsx +++ b/desktop/src/apps/BrowserApp/SettingsPanel.tsx @@ -17,6 +17,7 @@ import { type SearchEngine, } from "@/stores/browser-settings-store"; import { AgentCapabilitiesPanel } from "./AgentCapabilitiesPanel"; +import { SitePermissionsPanel } from "./SitePermissionsPanel"; import { bootstrapPushSubscription } from "../../lib/browser-push-bootstrap"; interface SettingsPanelProps { @@ -33,6 +34,7 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) { const setSearchEngine = useBrowserSettingsStore((s) => s.setSearchEngine); const ref = useRef(null); const [capsOpen, setCapsOpen] = useState(false); + const [sitePermsOpen, setSitePermsOpen] = useState(false); const [notifPermission, setNotifPermission] = useState( typeof Notification !== "undefined" ? Notification.permission : "default", ); @@ -66,8 +68,8 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) { }; }, [onClose]); - // Escape key dismiss — when the capabilities sub-modal is open, Escape - // should close it first; a second Escape then closes the settings panel. + // Escape key dismiss — when a sub-modal is open, Escape closes it first; + // a second Escape then closes the settings panel. useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key !== "Escape") return; @@ -75,11 +77,15 @@ export function SettingsPanel({ profileId, onClose }: SettingsPanelProps) { setCapsOpen(false); return; } + if (sitePermsOpen) { + setSitePermsOpen(false); + return; + } onClose(); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [onClose, capsOpen]); + }, [onClose, capsOpen, sitePermsOpen]); return (
)} + {/* Site permissions */} +
+ +
+ + {sitePermsOpen && ( + setSitePermsOpen(false)} + /> + )} + {/* Notifications */}
Notifications diff --git a/desktop/src/apps/BrowserApp/SitePermissionsPanel.test.tsx b/desktop/src/apps/BrowserApp/SitePermissionsPanel.test.tsx new file mode 100644 index 00000000..d5af2b55 --- /dev/null +++ b/desktop/src/apps/BrowserApp/SitePermissionsPanel.test.tsx @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, act, waitFor } from "@testing-library/react"; +import { SitePermissionsPanel } from "./SitePermissionsPanel"; +import * as sitePermsApi from "@/lib/browser-site-permissions-api"; + +vi.mock("@/lib/browser-site-permissions-api"); + +const PROFILE_ID = "prof-1"; + +function makeGrant( + overrides: Partial = {}, +): sitePermsApi.SitePermissionGrant { + return { + host_pattern: "*.example.com", + permission: "geolocation", + state: "allow", + ...overrides, + }; +} + +beforeEach(() => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([]); + vi.mocked(sitePermsApi.revokeSitePermission).mockResolvedValue(true); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("SitePermissionsPanel", () => { + it("shows loading state initially", async () => { + let resolve!: (v: sitePermsApi.SitePermissionGrant[]) => void; + vi.mocked(sitePermsApi.listSitePermissions).mockReturnValue( + new Promise((r) => { resolve = r; }), + ); + + render(); + expect(screen.getByText(/loading/i)).toBeTruthy(); + resolve([]); + }); + + it("renders one row per permission grant", async () => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([ + makeGrant({ host_pattern: "foo.com", permission: "camera" }), + makeGrant({ host_pattern: "bar.com", permission: "notifications" }), + ]); + + render(); + await waitFor(() => screen.getByText("foo.com")); + expect(screen.getByText("bar.com")).toBeTruthy(); + expect(screen.getByText("camera")).toBeTruthy(); + expect(screen.getByText("notifications")).toBeTruthy(); + }); + + it("shows empty-state message when no grants", async () => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([]); + + render(); + await waitFor(() => screen.getByText(/no site permissions yet/i)); + }); + + it("revoke button calls revokeSitePermission with host_pattern and permission", async () => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([ + makeGrant({ host_pattern: "secure.com", permission: "geolocation" }), + ]); + vi.mocked(sitePermsApi.revokeSitePermission).mockResolvedValue(true); + // After revoke the list comes back empty + vi.mocked(sitePermsApi.listSitePermissions) + .mockResolvedValueOnce([makeGrant({ host_pattern: "secure.com", permission: "geolocation" })]) + .mockResolvedValueOnce([]); + + render(); + await waitFor(() => screen.getByText("secure.com")); + + const revokeBtn = screen.getByRole("button", { name: /revoke geolocation on secure\.com/i }); + await act(async () => { + fireEvent.click(revokeBtn); + }); + + expect(sitePermsApi.revokeSitePermission).toHaveBeenCalledWith( + PROFILE_ID, + "secure.com", + "geolocation", + ); + await waitFor(() => screen.getByText(/no site permissions yet/i)); + }); + + it("revoke failure shows inline error", async () => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([ + makeGrant({ host_pattern: "fail.com", permission: "camera" }), + ]); + vi.mocked(sitePermsApi.revokeSitePermission).mockResolvedValue(false); + + render(); + await waitFor(() => screen.getByText("fail.com")); + + const revokeBtn = screen.getByRole("button", { name: /revoke camera on fail\.com/i }); + await act(async () => { + fireEvent.click(revokeBtn); + }); + + await waitFor(() => screen.getByText(/failed to revoke/i)); + }); + + it("Esc closes via onClose", async () => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([]); + const onClose = vi.fn(); + + render(); + await waitFor(() => screen.getByRole("dialog")); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("backdrop click closes via onClose", async () => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([]); + const onClose = vi.fn(); + + render(); + const backdrop = await waitFor(() => screen.getByRole("dialog").parentElement!); + + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it("role=dialog + aria-modal + aria-label present", async () => { + vi.mocked(sitePermsApi.listSitePermissions).mockResolvedValue([]); + + render(); + const dialog = await waitFor(() => screen.getByRole("dialog")); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(dialog.getAttribute("aria-label")).toMatch(/site permissions/i); + }); +}); diff --git a/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx b/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx new file mode 100644 index 00000000..74496a22 --- /dev/null +++ b/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx @@ -0,0 +1,151 @@ +/** + * BrowserApp v2 — SitePermissionsPanel. + * + * Table of all per-host permission grants for the current profile. + * Columns: host_pattern | permission | state | revoke button + * + * Mounted as a section inside SettingsPanel, similar to AgentCapabilitiesPanel. + */ +import { useEffect, useRef, useState } from "react"; +import { X } from "lucide-react"; +import { + listSitePermissions, + revokeSitePermission, + type SitePermissionGrant, +} from "@/lib/browser-site-permissions-api"; + +interface SitePermissionsPanelProps { + profileId: string; + onClose(): void; +} + +export function SitePermissionsPanel({ profileId, onClose }: SitePermissionsPanelProps) { + const [grants, setGrants] = useState(null); + const [error, setError] = useState(null); + const loadSeqRef = useRef(0); + + async function load() { + const seq = ++loadSeqRef.current; + setError(null); + try { + const list = await listSitePermissions(profileId); + if (seq !== loadSeqRef.current) return; + setGrants(list); + } catch { + if (seq !== loadSeqRef.current) return; + setError("Failed to load site permissions. Please try again."); + setGrants([]); + } + } + + useEffect(() => { + load(); + }, [profileId]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + async function handleRevoke(grant: SitePermissionGrant) { + setError(null); + try { + const ok = await revokeSitePermission(profileId, grant.host_pattern, grant.permission); + if (!ok) { + setError("Failed to revoke permission. Please try again."); + return; + } + await load(); + } catch { + setError("Failed to revoke permission. Please try again."); + } + } + + return ( +
+
e.stopPropagation()} + > +
+

Site permissions

+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ {grants === null ? ( +

Loading…

+ ) : grants.length === 0 ? ( +

No site permissions yet

+ ) : ( + + + + + + + + + + + {grants.map((grant) => ( + + + + + + + ))} + +
Host patternPermissionStateRevoke
+ + {grant.host_pattern} + + + + {grant.permission} + + {grant.state} + +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/lib/browser-site-permissions-api.ts b/desktop/src/lib/browser-site-permissions-api.ts new file mode 100644 index 00000000..5758dec3 --- /dev/null +++ b/desktop/src/lib/browser-site-permissions-api.ts @@ -0,0 +1,53 @@ +/** + * Fetch wrappers for /api/desktop/browser/site-permissions. + * All functions are silent on errors (return empty/false). + * + * Backend: tinyagentos/routes/desktop_browser/site_permission_routes.py + * + * Response shapes: + * GET /api/desktop/browser/site-permissions?profile_id=… + * → { grants: SitePermissionGrant[] } + * DELETE /api/desktop/browser/site-permissions?profile_id=…&host_pattern=…&permission=… + * → 204 No Content + */ + +export interface SitePermissionGrant { + host_pattern: string; + permission: string; + state: string; // 'allow' | 'deny' +} + +export async function listSitePermissions(profileId: string): Promise { + const params = new URLSearchParams({ profile_id: profileId }); + try { + const resp = await fetch(`/api/desktop/browser/site-permissions?${params}`, { + credentials: "include", + }); + if (!resp.ok) return []; + const body = await resp.json(); + return Array.isArray(body?.grants) ? body.grants : []; + } catch { + return []; + } +} + +export async function revokeSitePermission( + profileId: string, + hostPattern: string, + permission: string, +): Promise { + const params = new URLSearchParams({ + profile_id: profileId, + host_pattern: hostPattern, + permission, + }); + try { + const resp = await fetch(`/api/desktop/browser/site-permissions?${params}`, { + method: "DELETE", + credentials: "include", + }); + return resp.ok; + } catch { + return false; + } +} From 81832060153e8ef3aa4c501f5fea2cd0ae4b7d26 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 5 May 2026 17:53:31 +0100 Subject: [PATCH 3/3] fix(browser): address CR findings on PR 9 follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listSitePermissions now throws on non-2xx / network failure instead of returning []. The panel had explicit error UX that was unreachable because the wrapper collapsed all failures into 'no permissions yet'. Reads now surface real failures so the user sees the retry message. - handleRevoke now guards against duplicate clicks while a request is in flight via a revokingKey state. The button is disabled across all rows during revoke so a fast double-click can't fire two DELETEs for the same row or interleave revokes for different rows. Note: CR/Kilo's CRITICAL on BookmarksBar.test.tsx:117 was a false positive — both reviewers miscounted. truncate(20) does slice(0,20) which yields chars 0-19 = 'A Very Long Title Th' (20 chars), then appends '…'. Test correctly expects 'A Very Long Title Th…'. --- .../apps/BrowserApp/SitePermissionsPanel.tsx | 11 +++++++++- .../src/lib/browser-site-permissions-api.ts | 21 ++++++++++--------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx b/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx index 74496a22..db9a6c67 100644 --- a/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx +++ b/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx @@ -22,8 +22,11 @@ interface SitePermissionsPanelProps { export function SitePermissionsPanel({ profileId, onClose }: SitePermissionsPanelProps) { const [grants, setGrants] = useState(null); const [error, setError] = useState(null); + const [revokingKey, setRevokingKey] = useState(null); const loadSeqRef = useRef(0); + const revokeKey = (g: SitePermissionGrant) => `${g.host_pattern}|${g.permission}`; + async function load() { const seq = ++loadSeqRef.current; setError(null); @@ -51,6 +54,9 @@ export function SitePermissionsPanel({ profileId, onClose }: SitePermissionsPane }, [onClose]); async function handleRevoke(grant: SitePermissionGrant) { + const key = revokeKey(grant); + if (revokingKey) return; // already revoking another (or same) row + setRevokingKey(key); setError(null); try { const ok = await revokeSitePermission(profileId, grant.host_pattern, grant.permission); @@ -61,6 +67,8 @@ export function SitePermissionsPanel({ profileId, onClose }: SitePermissionsPane await load(); } catch { setError("Failed to revoke permission. Please try again."); + } finally { + setRevokingKey(null); } } @@ -134,7 +142,8 @@ export function SitePermissionsPanel({ profileId, onClose }: SitePermissionsPane type="button" aria-label={`Revoke ${grant.permission} on ${grant.host_pattern}`} onClick={() => handleRevoke(grant)} - className="p-1 rounded hover:bg-red-500/20 text-shell-text-secondary hover:text-red-400" + disabled={revokingKey !== null} + className="p-1 rounded hover:bg-red-500/20 text-shell-text-secondary hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed" > diff --git a/desktop/src/lib/browser-site-permissions-api.ts b/desktop/src/lib/browser-site-permissions-api.ts index 5758dec3..414b0051 100644 --- a/desktop/src/lib/browser-site-permissions-api.ts +++ b/desktop/src/lib/browser-site-permissions-api.ts @@ -1,6 +1,9 @@ /** * Fetch wrappers for /api/desktop/browser/site-permissions. - * All functions are silent on errors (return empty/false). + * + * Reads throw on non-2xx / network failure so callers can show a real + * error UX rather than collapsing failures into an empty list. Writes + * return a boolean to keep call sites simple. * * Backend: tinyagentos/routes/desktop_browser/site_permission_routes.py * @@ -19,16 +22,14 @@ export interface SitePermissionGrant { export async function listSitePermissions(profileId: string): Promise { const params = new URLSearchParams({ profile_id: profileId }); - try { - const resp = await fetch(`/api/desktop/browser/site-permissions?${params}`, { - credentials: "include", - }); - if (!resp.ok) return []; - const body = await resp.json(); - return Array.isArray(body?.grants) ? body.grants : []; - } catch { - return []; + const resp = await fetch(`/api/desktop/browser/site-permissions?${params}`, { + credentials: "include", + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}`); } + const body = await resp.json(); + return Array.isArray(body?.grants) ? body.grants : []; } export async function revokeSitePermission(