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 && ( 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..db9a6c67 --- /dev/null +++ b/desktop/src/apps/BrowserApp/SitePermissionsPanel.tsx @@ -0,0 +1,160 @@ +/** + * 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 [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); + 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) { + 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); + if (!ok) { + setError("Failed to revoke permission. Please try again."); + return; + } + await load(); + } catch { + setError("Failed to revoke permission. Please try again."); + } finally { + setRevokingKey(null); + } + } + + 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..414b0051 --- /dev/null +++ b/desktop/src/lib/browser-site-permissions-api.ts @@ -0,0 +1,54 @@ +/** + * Fetch wrappers for /api/desktop/browser/site-permissions. + * + * 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 + * + * 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 }); + 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( + 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; + } +}